320H.14 - React Redux Integrations and Optimizations
Learning Objectives
By the end of this lesson, learners will be able to:
- Describe the purpose of the
react-redux
library. - Describe the function of React Redux, and how it creates a "binding layer".
- Describe the function of the
<Provider>
component fromreact-redux
, and its relationship to React context. - Describe the purpose of the
useSelector
,useDispatch
, anduseState
React Redux hooks. - Optimize the use of
useSelector
by providing anequalityFn
. - Explain the benefits and downsides of putting complex logic inside of selector functions to derive state information.
- Explain the concept of "memoization" for the caching of data.
- Create memoized selectors using the Redux Toolkit
createSelector
function. - Automatically generate memoized selectors, CRUD reducer functions, and sorted normalized state data through the use of Redux Toolkit's
createEntityAdapter
function.
CodeSandbox
This lesson uses CodeSandbox as one of its tools.
If you are unfamiliar with CodeSandbox, or need a refresher, please visit our reference page on CodeSandbox for instructions on:
- Creating an Account
- Making a Sandbox
- Navigating your Sandbox
- Submitting a link to your Sandbox to Canvas
React Redux
React and Redux go hand in hand. As demonstrated in the many examples thus far. However, outside of the brief mention that the <Provider>
component from react-redux
is required to integrate the two, we have not yet discussed the many other features of react-redux
in detail.
Redux itself is a standalone library that can be used with any UI layer or framework. This includes React, but Redux can also be used with Angular, Vue, Ember, vanilla JS, and more. Since React and Redux are technically independant, the React Redux binding library is used to bridge the gap between the two, rather than directly interacting with the store from within your UI code.
React Redux is the official React UI bindings layer for Redux. It is what allows your React components to dispatch actions to the store to update state, and read that state data from the store.
Other than convenience, one of the major benefits of react-redux
is that it implements performance optimizations for you. React updates all components inside of a component tree when state for the top component changes, even if the data for those lower-level components hasn't changed. React Redux implements many performance optimizations internally, ensuring that your components only re-render when they actually need to. For the few exceptions to this, React Redux and the Redux Toolkit provide wonderful optimization tools and functions.
Let's take a detailed look at the features of react-redux
so we can have a better understanding of how our applications work, and perhaps spark some new ideas based on that understanding.
The Provider Component
The react-redux
<Provider>
component is very similar to the React useContext
hook's <Context.Provider>
in many ways. It should not surprise you to learn that React Redux uses React's "context" feature internally to make the Redux store available to deeply nested components. It allows us to lift state to the top of our application without the need for subsequent prop drilling; we can use that state from anywhere beneath the provider.
So can Redux be replaced entirely with React hooks? By now, you've surely realized the answer is "no," or at least "not easily."
Redux handles complex state updates and asynchronous actions more efficiently than useContext
, and also provides a very clear separation of concerns between application logic and state management. Redux also has the benefit of improving the testability of your application in many cases, since all state updates are managed through a single store, whereas you could create dozens of different contexts within React.
By wrapping our application with <Provider>
, we are beginning the process of centralizing all of the information we need to make our application work. Just like <Context.Provider>
needs a value
, don't forget to provide your React Redux Provider
with a store
.
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'
const root = createRoot(document.getElementById('root'))
root.render(
<Provider store={store}>
<App />
</Provider>
)
store
is not the only prop that the Provider
can accept, but it's the only one that is typically used. If you encounter server-side rendering (SSR) scenarios, you may want to look into the documentation on the serverState
prop.
In addition to the React Redux hooks that gain access to the store through this provider, there is also a connect()
API that allows you to "connect" components directly to Redux state via their props
.
We will not be covering connect()
within this course, as the React Redux team recommends using the hooks API as the default. However, if you encounter connect()
in a codebase, take a look through the documentation; you can handle it!
Custom Provider Context
Another optional prop that can be given to <Provider>
is a custom context
. This is a very rare use case for application development, since Redux was built on using a single store providing a single context.
If, however, you are building something like a complex reusable component that makes use of Redux for state management, you will want to provide a custom context that does not interfere with any Redux store that the consumers of that component might use in their applications.
You also need to create custom hooks, in order to prevent collision there as well. react-redux
exports several hook creator functions in order to make this simple.
Here's how that might look:
import {
Provider,
createStoreHook,
createDispatchHook,
createSelectorHook,
} from 'react-redux'
const MyContext = React.createContext(null)
export const useStore = createStoreHook(MyContext)
export const useDispatch = createDispatchHook(MyContext)
export const useSelector = createSelectorHook(MyContext)
const myStore = createStore(rootReducer)
export function MyProvider({ children }) {
return (
<Provider context={MyContext} store={myStore}>
{children}
</Provider>
)
}
There is a lot more to discuss when considering a custom context, but it is beyond the scope of this lesson. If you ever need to implement or maintain an application with custom context, use the documentation to guide you.
React Redux Hooks
There are three commonly used hooks in React Redux, two of which we already have some experience with.
React Redux Hooks: useSelector
The useSelector
hook takes a single selector function, which will be called with the entire Redux store state as its only argument. The selector is run whenever the component containing it renders (unless its reference hasn't changed so that a cached result can be returned without re-running the selector).
useSelector
also subscribed to the Redux store, and runs the selector whenever an action is dispatched.
Selector functions can return any type of value, and that value does not necessarily need to be a piece of state (though it is typically at least derived from it). This means that you can create selectors that do more than simply access state.
For example, we could return the length of a state array:
const todosLength = useSelector((state) => state.todos.length)
Or a selector that provides commonly used calculated state information:
const completeTodosPercent = useSelector((state) => {
const todos = state.todos;
const total = todos.length;
const completed = todos.filter((todo) => todo.completed).length;
return completed/total;
})
The possibilities are endless. Since selectors can provide derived data from state, it allows us to store the minimal possible state within the Redux store. They also allows us to encapsulate the shape of the state, so that state.deeply.nested.field.somewhere
can be accessed simply and without prior knowledge of its location.
While selectors are almost always exported from the files containing their associated slices of state in order to provide this encapsulation, it is not uncommon to have inline selectors that adapt to the components that use them via the inclusion of props
:
import { useSelector } from 'react-redux'
export const TodoListItem = (props) => {
const todo = useSelector((state) => state.todos[props.id])
return <div>{todo.text}</div>
}
However, be careful with this approach! If your selector has internal state (more on this later), this will not work because a new instance of the selector is created whenever the component is rendered (because it is within the component). In order to prevent this, selectors with internal state should be declared outside of the component so that the same instance is used across renders.
So how would we extract this selector and still pass it props.id
, which is scoped to the TodoListItem
component?
Would the following work?
import { useSelector } from 'react-redux'
import { selectTodoById } from './todosSlice'
export const TodoListItem = (props) => {
const todo = useSelector(selectTodoById(state, props.id))
return <div>{todo.text}</div>
}
It will not work, because useSelector
only passed state
into the provided function; any other arguments will not be passed!
In order to circumvent this, you can wrap the "real" selector function in an inline anonymous selector that supplies its arguments, as long as they are within the correct scope.
import { useSelector } from 'react-redux'
import { selectTodoById } from './todosSlice'
export const TodoListItem = (props) => {
const todo = useSelector((state) => selectTodoById(state, props.id))
return <div>{todo.text}</div>
}
Extracting the selector function elsewhere has a number of benefits, including reuse. Ideally, only your reducer functions and selectors should know the exact state structure, so if you change where some piece of state lives, you only need to update those two pieces of logic. This also means that keeping all of this logic in one place (typically a slice file) can make it easier to maintain the code.
When an action is dispatched to the Redux store, useSelector
forces a re-render if the selector result appears to be different than the last result, as determined by a strict equals ===
reference comparison. This means that returning a new object from the selector will always force a re-render by default, even if the object contains the same fields and associated values.
In order to address this, useSelector
allows a second optional argument, equalityFn
. This allows you to define a custom equality function to determine whether or not the selector should force a re-render.
react-redux
exports a shallowEqual
function thaat can be used for this purpose, performing shallow comparisons when used as the equalityFn
within useSelector
:
const selectedData = useSelector(selectorReturningAnObject, shallowEqual)
By specifying this equality function, you can prevent uneccessary re-renders and increase performance.
The equalityFn
takes two arguments: the old value and the new value, and can contain any comparison logic that returns true
or false
:
const customEqualityFn = (oldVal, newVal) => {
// some comparison logic
return false;
}
Also, remember that you can always define and export custom hooks for functionality that you commonly use. For example, here's a custom hook that implements the "shallow equals" behavior:
import { useSelector, shallowEqual } from 'react-redux'
export function useShallowEqualSelector(selector) {
return useSelector(selector, shallowEqual)
}
React Redux Hooks: useDispatch
The useDispatch
hook returns a reference to the Redux store's dispatch
function, which you may use to dispatch actions when needed.
That's it, no hidden secrets from useDispatch
!
React Redux Hooks: useStore
In very rare cases, you may require direct access to the Redux store. Preferably, the store is only ever accessed through useSelector
and selector functions.
The useStore
hook provides access to the store
given to the Redux Provider
for these uncommon scenarios.
You can call store.getState()
, replace reducers, and more. Again, this should not be used frequently, but knowing that it exists is important.
Memoized Selectors
While the react-redux
package handles all of the binding between React and Redux, and exposes many useful functions with a lot of built-in optimization, there are still a few issues that can be overcome through the use of other libraries. For example...
Selector functions can contain lengthy calculations, or create derived values that contains large amounts of data, both of which can slow down your application. Consider the fact that selectors re-run after every dispatched action, regardless of which section of the Redux state was updated. This can become very expensive in terms of computing resources, even in smaller applications.
In order to address this, we create memoized selectors that avoid recalculating their results if the same inputs are passed into them. If you are familiar with React.memo()
or the useMemo
hook, this uses the same concepts.
Memoization is a form of caching which involves tracking the inputs to a function and the results of those inputs, which are stored for later reference. If the function is called with the same inputs, it doesn't bother recalculating the outputs (because they should be the same). Instead, it simply returns the cached value.
The Reselect library, included within the Redux Toolkit, provides an easy way to create memoized selectors via createSelector
.
import { createSelector } from '@reduxjs/toolkit'
createSelector
accepts any number of "input selector" functions, and an "output selector" function. It returns a new selector function for your use. The results of each input selector are provided as arguments to the output selector.
When the selector is run, Reselect runs the input selectors with the given arguments, strictly compares the results ===
, and if any of the results are different than before, it will re-run the output selector, passing those results in as arguments.
If all of the input selector results are the same, it skips re-running the output selector, simply returning the cached result.
In order for this to actually increase performance, input selectors should just extract and return values, and the output selector should do all of the work.
Here's a simple example of this from the Redux documentation:
const state = {
a: {
first: 5
},
b: 10
}
const selectA = state => state.a
const selectB = state => state.b
const selectA1 = createSelector([selectA], a => a.first)
const selectResult = createSelector([selectA1, selectB], (a1, b) => {
console.log('Output selector running')
return a1 + b
})
const result = selectResult(state)
// Log: "Output selector running"
console.log(result)
// 15
const secondResult = selectResult(state)
// No log output
console.log(secondResult)
// 15
By default, createSelector
only memoizes the most recent set of parameters. This means that if you repeatedly call a selector with two different alternating inputs, for example, it will need to recalculate the results every time, because it does not "look back" more than one calculation.
const a = someSelector(state, 1) // first call, not memoized
const b = someSelector(state, 1) // same inputs, memoized
const c = someSelector(state, 2) // different inputs, not memoized
const d = someSelector(state, 1) // different inputs from last time, not memoized
As an additional useful pattern, you can nest selectors within one another in order to prevent the duplication of logic. In the example below, a selector function selectTodos
is memoized using createSelector
in order to create the selectCompletedTodos
selector, which is used within selectCompletedTodoDescriptions
.
const selectTodos = state => state.todos
const selectCompletedTodos = createSelector([selectTodos], todos =>
todos.filter(todo => todo.completed)
)
const selectCompletedTodoDescriptions = createSelector(
[selectCompletedTodos],
completedTodos => completedTodos.map(todo => todo.text)
)
It is important to note that not every selector should be memoized. Memoization is only useful if you are deriving results and if those derived results would create new references each time they are calculated. Direct lookups and returns should not be memoized.
Here are some examples of appropriate and inappropriate memoization from the Redux documentation:
// ❌ DO NOT memoize: will always return a consistent reference
const selectTodos = state => state.todos
const selectNestedValue = state => state.some.deeply.nested.field
const selectTodoById = (state, todoId) => state.todos[todoId]
// ❌ DO NOT memoize: deriving data, but will return a consistent result
const selectItemsTotal = state => {
return state.items.reduce((result, item) => {
return result + item.total
}, 0)
}
const selectAllCompleted = state => state.todos.every(todo => todo.completed)
// ✅ SHOULD memoize: returns new references when called
const selectTodoDescriptions = state => state.todos.map(todo => todo.text)
Memoized selectors have internal state, and therefore must be kept outside of component definitions or they will not function as expected (due to creating a new reference with each render).
Prebuilt Reducers and Selectors with createEntityAdapter
Redux Toolkit provides one more very powerful tool for generating prebuilt reducers and selectors for performing create, read, update, and delete (CRUD) operations on a normalized state structure. The reducers it generates can be conveniently passed to createReducer
and createSlice
, or used as "mutating" helper functions inside of them.
If you are unfamiliar with the concept of "normalized state," take some time to review this article on normalizing state shape from the Redux team.
Here's the sample usage provided by the RTK documentation, which we will break down following the code:
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
const booksAdapter = createEntityAdapter({
// Assume IDs are stored in a field other than `book.id`
selectId: (book) => book.bookId,
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload.books)
},
},
})
const store = configureStore({
reducer: {
books: booksSlice.reducer,
},
})
console.log(store.getState().books)
// { ids: [], entities: {} }
// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors((state) => state.books)
// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())
createEntityAdapter
accepts a single object which contains two optional fields:
selectId
accepts a singleEntity
instance, in this casebook
, and returns the value of whatever unique ID field is inside of that instance. If this field is not provided, its default implementation is(entity) => entity.id
.sortComparer
is a callback function that accepts toEntity
instances, returning a standard numeric sort result (1, 0, -1) to indicate their relative order for sorting. In this case, we're using alocaleCompare
method to sort bytitle
. If this field is provided,state.ids
will be kept sorted, so that mapping over the IDs should result in a sorted array of entities. If not, no guarantees are made about the ordering.
The return from createEntityAdapter
is an "entity adapter" instance, which is a plain JavaScript object that contains a number of things, including:
- The generated reducer functions, stored in
.reducer
. - The original
selectId
andsortComparer
callbacks. - A method to generate an initial state value,
getInitialState()
. - Functions to generate a set of memoized selectors for the entity type,
getSelectors()
.
Above, we make use of the addOne
, setAll
, and selectAll
CRUD functions of the entity adapter, but there are many more. Here is a reference list of each function and its purpose, taken from the RTK documentation:
addOne
: accepts a single entity, and adds it if it's not already present.addMany
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
, and adds them if not already present.setOne
: accepts a single entity and adds or replaces itsetMany
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
, and adds or replaces them.setAll
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
, and replaces all existing entities with the values in the array.removeOne
: accepts a single entity ID value, and removes the entity with that ID if it exists.removeMany
: accepts an array of entity ID values, and removes each entity with those IDs if they exist.removeAll
: removes all entities from the entity state object.updateOne
: accepts an "update object" containing an entity ID and an object containing one or more new field values to update inside achanges
field, and performs a shallow update on the corresponding entity.updateMany
: accepts an array of update objects, and performs shallow updates on all corresponding entities.upsertOne
: accepts a single entity. If an entity with that ID exists, it will perform a shallow update and the specified fields will be merged into the - existing entity, with any matching fields overwriting the existing values. If the entity does not exist, it will be added.upsertMany
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
that will be shallowly upserted.
In many cases, these automatically generated functions are all you need for a successful CRUD application! They can be passed directly as case reducers to createReducer
or createSlice
, but they do not have to coorespond to any Redux actions (createEntityAdapter
does not automatically generate associated actions). You decide if and how you use each of the functions.
It is important to note some differences in behavior between the various insertion functions above.
If an entity already exists within state:
addOne
andaddMany
will do nothing with the new entity.setOne
andsetMany
will completely replace the old entity with the new one. This will also get rid of any properties on the entity that are not present in the new version of said entity.upsertOne
andupsertMany
will do a shallow copy to merge the old and new entities overwriting existing values, adding any that were not there and not touching properties not provided in the new entity.
The selectors returned from EntityAdapter.getSelectors()
are as follows:
selectIds
: returns thestate.ids
array.selectEntities
: returns thestate.entities
lookup table.selectAll
: maps over thestate.ids
array, and returns an array of entities in the same order.selectTotal
: returns the total number of entities being stored in this state.selectById
: given the state and an entity ID, returns the entity with that ID orundefined
.
Each of these selectors is created with createSelector
behind the scenes, meaning that they are automatically memoized for you.
As you can imagine, createEntityAdapter
is quite useful in practice (which you will get shortly).
Summary
The React Redux library is the official React UI bindings layer for Redux. It is what allows your React components to dispatch actions to the store to update state, and read that state data from the store.
The Redux store is provided to React components through the use of the React Redux <Provider>
component, which makes use of React's context mechanism.
Once provided, the store can be accessed in a number of ways through the React Redux hooks:
useSelector
, which allows you to access state values and return them, or return results derived from state.useDispatch
, which provides a reference to the store'sdispatch
method, allowing you to dispatch actions to it.useStore
, which provides a direct reference to the Reduxstore
for very rare use cases.
React Redux handles a lot of optimization for us, but sometimes it is necessary to step into external libraries to create a more performative application. The Redux Toolkit provides two alternatives to the React Redux useSelector
function: createSelector
and createEntityAdapter
.
createSelector
returns memoized selector functions, which cache their inputs and results in order to prevent expensive calculations if those inputs do not change between renders.
createEntityAdapter
also created memoized selector functions, but provides a more comprehensive return that also includes CRUD reducer functions. createEntityAdapter
only works alongside normalized state, but also includes additional features like sorting and initial state generation.
A React + Redux Application: Part Four
Now that we've solidified our understanding of how React Redux binds the two together, and taken a look at some performative alternatives provided by the Redux Toolkit, we can round out part four of GLAB 320.11.1 - Redux State Management with some additional features and performance optimizations.
When you're finished, your application will be officially complete!