Many articles have been written about React state management strategies for React, and yes, this is yet another one of them. But rather than talking about a new library and how everyone should adopt it, I want to talk about two different philosophical approach to state: centralised vs decentralised.
The decentralised state paradigm
Decentralised state has been the battery-included solution since the first version of React. Class components can edit local state to trigger a re-render, and functional components can accomplish the same with the useState
hook.
Local state is very intuitive to manipulate because it sits near the component that uses it for display and side effects, it's allocated when needed, and disappears when the component is unmounted.
The centralised state paradigm
Keeping all mutable state in one place is an approach championed by Elm, ClojureScript and Redux: a single data structure (usually a tree) contains all the state needed to render your React application. When a branch of that tree changes state, the relevant components in your React hierarchy re-render displaying the new information.
A single point of reference for the entire app state is a very neat idea and feels like a natural evolution on top of React itself: React abstracts DOM changes with components, the app state abstracts component changes with a big JSON value.
Since all changes are represented with data, the logic that transitions the app through different states is very easy to express and to test: it's just a function f(currentState, event) => newState
. It's also easy to generate many valid UI screens by mocking the app state.
speccards generates random valid UI states based on a spec
Problems with decentralised state
Local state becomes problematic when two components need to watch and act on the same state. Think about a simple currency converter where the user can edit either currency text input.
In this case the naive solution is to "lift the state up" to the closest parent and give all children callback functions to manipulate the state.
Lifting State Up in vanilla React
This rarely scales for bigger applications, so something like Recoil might be needed for more powerful sharing.
Problems with centralised state
You can easily get started with centralised state with the useReducer
hook and keeping all state inside the top <App/>
component, passing down state as props
wherever they are needed. What you'll find soon enough, though, is that components far away from the root will require forever more information, and you'll have to drill all the props through the tree until they reach the correct component.
This, again, doesn't scale for large applications, so some forms of subscriptions
to parts of the state tree are required to keep the app performant and easy to extend. You might need something like the hook version of Redux
How the two compare in terms of Developer Experience
I have always been a huge advocate of the centralised approach. It appealed to me because of its functional purity and how it made accessing and manipulate state much easier in the pre-hook era. I have since changed my mind, and I'd like to explain why.
Centralised state gives you the illusion of correctness. The state neatly transitions from one pure value to another thanks little functions that encapsulate your business logic. You might even have tests for those functions that say something like:
- When button is clicked
- Set the loading indicator to true
- Clear the input field
- Send an http call to the server
Except that when you actually run the app, the behaviour is not what you expected. The problem is that your logic is far removed from your UI components, and while sound and correct in principle, it doesn't play well with your render code. It might be because the state has a different shape, but more often that not, it's because the intended values are not added to the state at the right time.
Modelling your app as a succession of states works well when you deal mostly with synchronous interactions, like a game, but starts to show its limit with more asynchronous behaviour, like fetching new data over the internet. It's easy to describe how the data changes but not how the data gets there in the first place. So you might end up with an infinite spinner because an action has not been triggered or received at the right time (e.g. when the user navigated to a new screen).
Annoying when that happens eh?
Colocating state, effects and ui
Advocates of the centralised state approach have a great argument: UI should not care about state management and side effects, it should just care about displaying values. Extract the logic somewhere else and test it.
While poignant in principle, this doesn't work well in practice. The reverse tends to be true: it's useful to think about components as fragments of the screen with a well defined behaviour. They can trigger actions, listen to events and manipulate state that affects other components.
Over the last year I've been writing components with complex behaviour, fully expecting things to break and knowing I'll be refactoring them with some centralised solution. To my surprise, this nontrivial, large app, kept on getting bigger and accumulating new features without getting significantly more complex. I would even say it scaled in ways that Redux, ClojureScript or Elm apps haven't been in the past, even with less "formal proofs" (tests, types) of correctness.
A hook with a name
Consider this component
function ItemList() {
const { auth } = useUser()
const { itemsPerPage } = usePreferences()
const { searchQuery } = useSearch()
const items = useItems({ auth, itemsPerPage, searchQuery })
return <ul>{items.map(...)}</ul>
}
To me it clearly communicates its dependencies (inputs) and how it uses them for display. Moreover, these inputs are not just static data but a combination of state and effect.
-
useUser
returns the current user info if logged in, otherwise it triggers an authentication flow -
usePreferences
gets saved data asynchronously from IndexedDB -
useSearch
extracts the query parameters in the current url -
useItems
ties everything together, getting the items server side and caching them for future uses.
When each one of these dependencies change (the url parameters, or the local storage being cleared, or the user token expiring) the hooks will take care of re-fetching and re-rendering the component.
Custom hooks that encapsulate caching are also a great way to share data between components: user, preferences, and remote data all reside in-memory and only need to be fetched once. The first component that uses the hook triggers the async behaviour and all the other components can subsequently access it free of charge.
You can even go one step beyond and wrap this behaviour in a hook itself:
function useSearchItems() {
const { auth } = useUser()
const { itemsPerPage } = usePreferences()
const { searchQuery } = useSearch()
const items = useItems({ auth, itemsPerPage, searchQuery })
return items
}
So you can have the same list of items in two different part of the application (say: the list itself and a Count indicator somewhere else).
Hooks can be seen as a dependency graph: what they "require" (other hook calls) and what they provide (the returned value). Editing this graph is really easy if you stick with React hooks and don't go outside of it with centralised state management.
This patterns composes really well and scales as you create new components. Need another components to access this list? It's a one line change. Don't need access to that data anymore? Removing the hook or the component itself means it's not fetched in that screen anymore.
State (what's cached), behaviour (which effects load and invalidate the data) and UI can be closely linked together again, so they evolve organically together with your app.
There's an app for that
Writing hooks manually that load, cache and invalidate it's obviously time consuming, and there's no need to do that when there are great libraries out there already.
React Query obviously stands out with its incredible DX. It works with http, graphql and any asynchronous behaviour really (e.g. IndexedDB).
This library alone gives you so much leverage it might be the only solution you need. If we're talking about state sharing without asynchronous behaviour, then Recoil might be a good addition too. If you're using custom hooks with a descriptive name, it won't really matter which library you use underneath.
// Prefer this
const repos = useRepos()
// To this
const { data: repos } = useQuery('repoData', () => fetch('https://api.github.com/repos/tannerlinsley/react query').then(res => res.json()))
A real world example
It's always best to show how ideas are implemented in practice rather than just talking about them so I've created firebuzz.app
Players can buzz to answer questions and receive points
You can browse the source code (and it particular the hooks
folder) over here https://github.com/frankiesardo/firebuzz.
Top comments (4)
Very well done
Thank you Alain! Just curious: did you try the app? What do you think of it?
Not yet, but I sincerely think named hooks are a great idea! They seem to nicely solve most problems I can think of. I'll give a try to the demo
I have actually tried it, it's interesting and funny LOL