DEV Community

Cover image for Share state using custom hooks
Francesco Sardo
Francesco Sardo

Posted on

Share state using custom hooks

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.

Screenshot 2021-08-11 at 21.27.07

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.

Screenshot 2021-08-15 at 21.45.26
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.

Screenshot 2021-08-15 at 21.49.56
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
Enter fullscreen mode Exit fullscreen mode

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).

1_8NJgObmgEVhNWVt3poeTaA
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> 
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()))
Enter fullscreen mode Exit fullscreen mode

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

WhatsApp Image 2021-08-01 at 19.57.27
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)

Collapse
 
alaindet profile image
Alain D'Ettorre

Very well done

Collapse
 
frankiesardo profile image
Francesco Sardo

Thank you Alain! Just curious: did you try the app? What do you think of it?

Collapse
 
alaindet profile image
Alain D'Ettorre

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

Thread Thread
 
alaindet profile image
Alain D'Ettorre

I have actually tried it, it's interesting and funny LOL