There is almost an infinite number of solutions for the problem most React applications face – state management. This post discusses a pragmatic and simple and yet powerful approach using the Context system.
Published on Mon, September 28, 2020
First of all: I don't think there is anything wrong with using solutions like Redux. I was so convinced that I took the idea and implemented a Redux store for .NET myself a few years ago. That implementation is still used in production for mobile apps that are under active development.
That being said, I today believe that most of the state an application contains can be dealt with locally at a component level. For example, it's not necessarily of interest to the whole app what a text field's content somewhere down in the hierarchy is. But when there is something that concerns the entire app, e.g., information about the currently logged-in user, then a way to hold and update that state on a global level is needed.
My specific requirements for such a solution are:
Well... the first three things sound a lot like MVU, don't they? And indeed, I figured out a pragmatic way that works quite well for me so far.
The idea is as simple as probably known: There is a central application state (Model) that is being passed to the components anywhere in the hierarchy (View), which can, in turn, dispatch messages that are then processed by a global update function to create an updated state (Update).
As already shown before, the useReducer
hook can be used to implement the general uni-directional data-flow quite easily for a React app. That hook will be one of the cornerstones of this implementation as well. The second one is the useContext
hook, which gives access to React's Context API from within function components. Besides, the state will be read from and written to the browser's local storage – which is persisted across sessions.
So the general workflow will be as follows:
Note: A full example can be found on GitHub.
It all starts by defining our AppState
which, for this example, contains one property – the authenticated user's name, either undefined (signed out), or a string (signed in).
declare interface AppState {
authenticatedUser: string | undefined;
}
Now we need some messages to define how that state can change:
declare type AppMsg =
| { type: "SignIn", user: string }
| { type: "SignOut" };
Our update function is now processing those messages:
function update(state: AppState, msg: AppMsg): AppState {
switch (msg.type) {
case "SignIn":
return { ...state, authenticatedUser: msg.user };
case "SignOut":
return { ...state, authenticatedUser: undefined };
}
}
We have a global state, messages that define what should happen to that state, and an update function that makes it happen.
To make that work, we now only need to wire it up at the root node. In our example, that's App.ts
. Here, we first get the persisted state from the browser's local storage:
const [persistedState, setPersistedState] = useLocalStorage(
"State",
{} as AppState
);
We can now use that persisted state to initialize the useReducer
hook, which will take care of our in-memory state. Note that we also want to persist any changes made to the state, so we have to intercept those calls of the update function. That is done by wrapping it into another function, updateAndPersistState()
, which has the same signature and, therefore, can be passed to useReducer
.
const updateAndPersistState = (state: AppState, msg: AppMsg) => {
const updatedState = Update(state, msg);
setPersistedState(updatedState);
return updatedState;
};
const [state, dispatch] = useReducer(updateAndPersistState, persistedState);
We now have the two key elements we want to use within the application's components: The state and a dispatch function. To make them available to the components through the useContext
hook, we will create two contexts: One for the state, one for the dispatch function.
Having those two split up into two contexts is useful as it allows components to only dispatch messages without being re-rendered.
Our contexts are held in their own module so we can use them anywhere:
import { Dispatch, createContext } from "react";
export const defaultAppState: AppState = {
authenticatedUser: undefined,
};
export default {
state: createContext<AppState>({} as AppState),
dispatch: createContext<Dispatch<AppMsg>>(() => {}),
};
But first, we need to set them up at the root node:
<AppContext.dispatch.Provider value={dispatch}>
<AppContext.state.Provider value={state}>
{state.authenticatedUser !== undefined
? "Signed in as: " + state.authenticatedUser
: "Not signed in."}
<hr />
<Child />
</AppContext.state.Provider>
</AppContext.dispatch.Provider>
Last but not least, a simple child component example:
interface ButtonProps {
dispatch: Dispatch<AppMsg>;
}
const SignIn = (props: ButtonProps) => (
<button onClick={() => props.dispatch({ type: "SignIn", user: "Foo" })}>
Sign in
</button>
);
const SignOut = (props: ButtonProps) => (
<button onClick={() => props.dispatch({ type: "SignOut" })}>Sign out</button>
);
export default function () {
const state = useContext(AppContext.state);
const dispatch = useContext(AppContext.dispatch);
return (
<div>
{state.authenticatedUser ? (
<SignOut dispatch={dispatch} />
) : (
<SignIn dispatch={dispatch} />
)}
</div>
);
}
First, we read the current state and check if authenticatedUser
is being set. If so, we show the sign-out button. If its value is undefined
instead, we know there is no user, so we offer the sign-in button instead.
Both buttons dispatch a message in their on-click handler accordingly. Those messages are sent to the update function, which returns an updated version of the state, causing a new render cycle for all involved components.
Let's see if all requirements were met:
That's it!