When I first started developing in the React ecosystem several years ago, I was introduced to the idea of the Flux pattern of application state management, which includes tools like Redux, Flow, and MobX. I developed with Redux for a while and really came to like it, even using it to power a couple of state machine applications written in node that never had anything to do with React or the front end.
The core tenets of the Flux pattern are that:
- Rather than individual components knowing things, there's a single source of truth for what's happening in your application.
- The application state should only change when the user performs some action with the UI (or whenever data finishes fetching, but that's usually as a result of some earlier action).
- Actions shouldn't directly update the state, but should be "dispatched" to a central clearinghouse that contains all of the logic for updating the state.
Basically, there's always one place where any component can go to get information, and one place where any component can go to say some action has been performed. Redux implements this pattern through a "reducer function." This function gets executed every time an action is dispatched to it, with two parameters -- the current state and an object which defines the action -- and uses them to generate a new state, which then becomes the new source of truth for the whole application.
I like this pattern, even if there's some challenges getting it to work with React. React components' rendering functions only fire when the props which they're passed by their parent component change. They can't, by themselves, set up listeners to an application state which is deliberately stored elsewhere. If that global state changes, it doesn't mean that the change is automatically reflected within the application's UI, which pretty much defeats the whole purpose.
One quick and dirty solution would be to keep the application state within the root component for an application and pass down prop values (and the callback prop necessary to dispatch actions) as far as necessary. The problem is that once you hit any sort of complexity within an application, always passing a ton of props becomes unwieldy and a significant barrier to testing; you're sending (lots of) named parameters to components, purely so they can be passed along down the chain to whatever leaf component actually needs them. This is a not-great code smell which is commonly known as prop drilling.
Redux addressed this problem by creating connected components. Any components that you wish to have access to the global state and/or action dispatcher can be wrapped in a connect
function that the framework provides for this purpose.
Under the hood, this creates a higher-order component which wraps the one you've written with another that contains special subscription links to a Redux global state. It can provide to its child (subsets of) state and access to the dispatch as traditional props that would trigger a re-render whenever they are changed. It ends up with a lot of components that look like this:
const MyButton = (props) => {
return (
<button onClick={props.toggleButton}>
{ props.active ? "On" : "Off" }
</button>
)
}
const mapStateToProps = (state) => ({
buttonIsActive: state.buttonIsActive
})
const mapDispatchToProps = (dispatch) => {
toggleButton: () => dispatch({ type: "click_button" })
}
export default connect(mapStateToProps, mapDispatchToProps)(MyButton)
The release of React Hooks in early 2019 changed many conceptions around development patterns, as it suddenly became a lot easier and cleaner for components to know things about themselves. If all you need is a self-contained on/off state for a single button, you could suddenly replace several files' worth of structure and framework-specific solutions with just:
const [active, setActive] = React.useState(true)
The issue is complexity, though. One of the major benefits of the Flux pattern is that simple actions can be dispatched by any component that can be interacted with, without needing to know what would need to be updated and where; the update to the application state should be reacted to by whatever cares about that. useState
is fine for anything that will always be self-contained, but beyond that, you start getting back into the scaling problems that led to the popularity of the Flux pattern in the first place.
However, we can use a couple of the less commonly known Hooks provided by React together to establish both a global application state and dispatcher, providing a single source of truth and the dynamic re-rendering that makes React so useful.
First, let's meet useReducer
. If you're familiar with useState
, you know the pattern of calls to it returning a two-value array, namely, the current state value and a setter function. useReducer
has the same pattern, but instead of a simple value, it uses a Redux-style reducer function, and returns a complex application state along with a dispatcher to update the state with actions.
This is a trivial example of a single-action reducer function and an initial state value that we'll use in a moment. If you've ever written Redux, it should look pretty familiar.
// contexts/User/reducer.js
export const reducer = (state, action) => {
switch (action.type) {
case "toggle_button":
return {
...state,
active: !state.active
}
default:
return state
}
}
export const initialState = {
active: false
}
We can use this on its own in any React component to create a reducer function-powered state, but only available to that component:
const [state, dispatch] = React.useReducer(reducer, initialState)
To make something globally available, we need to pair it with useContext
. Context is a concept that was introduced in React a bit earlier than Hooks. With a little bit of work, it provides an alternative method for passing props to descendent components that need them while skipping any ancestors that don't.
The original version had you setting up two higher-order components - one on the parent that would provide props (and have callback props executed within its scope) and another on the grandchild that would receive those props and re-render if and when they changed. The syntax for the latter was... sometimes awkward, and thankfully Hooks provided useContext
that makes the consumer much easier to use.
In this next code sample, we're importing our reducer function and initial state from previously. We're then creating and exporting a component that
- Uses the reducer function to create and maintain an application state and dispatch, then
- Returns a higher order
Provider
component generated by theReact.createContext
call (which is not itself a hook). It passes the state and dispatch in an array as thevalue
prop to that higher order component.
// contexts/User/index.jsx
import React from "react"
import { reducer, initialState } from "./reducer"
export const UserContext = React.createContext({
state: initialState,
dispatch: () => null
})
export const UserProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<UserContext.Provider value={[ state, dispatch ]}>
{ children }
</UserContext.Provider>
)
}
Don't worry: that's absolutely the hardest part and that is a general pattern which should be independent of our individual reducer function's needs.
The next step is to wrap our entire application (or at least as much as would ever need access to the global state) in that Provider component. This is a pretty common look:
// components/App.jsx
import { UserProvider } from "../contexts/UserProvider"
// Some other components you've written for your app...
import Header from "./Header"
import Main from "./Main"
export default () => {
return (
<UserProvider>
<Header />
<Main />
</UserProvider>
)
}
Finally, any component that wants access to the global state and/or dispatch functions just needs to import the context and reference it in a useContext
hook:
// components/MyButton.jsx
import React from "react"
import { UserContext } from "../contexts/User"
export default () => {
const [ state, dispatch ] = React.useContext(UserContext)
return (
<button onClick={() => dispatch({ type: "toggle_button" })}>
{ state.active ? "On" : "Off" }
</button>
)
}
The resulting two-value array that we destructure into references to the global state
and dispatch
provided by the useReducer
call, since that's how we structured the array that we passed into the value
prop for the context's provider component. That's it!
Any number of components can use this context and a dispatched action from any of them that mutates the state will update all of them appropriately. The reducer function can be easily updated with additional state properties and action types.