React is easy. An experienced engineer familiar with javascript can allegedly pick up the basics in an afternoon and be productive in a few days.

Writing React apps is hard. Between the tooling, the ecosystem, and the myriad different ways to solve common problems, an experienced engineer might still be learning important things after years of working in it full time.

One of the biggest challenges that separates the ease of learning React from the challenges of building larger react projects is State Management.

In Canada it’s called province management

State management is when you have some bit of data, like “is the sign-in modal shown/hidden?” or “user 3714’s name is Fred”.

Local state—state that lives entirely within one component—is React’s bread-and-butter:

const SomeComponent = () => {
  const [isModalShowing, setIsModalShowing] = useState(false);
  return <div>{isModalShowing && <Modal />}</div>;
};

But what about when some sub-component of SomeComponent needs to know about isModalShowing? Well, you can pass it down:

<SomeSubComponent isModalShowing={isModalShowing} />

But what if it’s not SomeSubComponent that needs it, but rather a deeply nested sub-sub-sub-sub-component? Well, you could have SomeComponent pass it to SomeSubComponent, which then passes it to SomeSubSubComponent, which passes it to SomeSubSubSubComponent, which passes it to SomeSubSubSubSubComponent. Now you have 2 extra components that need to have isModalShowing cluttering up their code even though they don’t do anything with it.

This is called Prop Drilling because you sorta “drill” isModalShowing down from the top of the component hierarchy to the component that needs it. It becomes unwieldy in larger codebases where you’ll create components that take in 20 properties, only to pass 18 of them on to sub-components.

If only there was a more painful and complex way

Enter the world of React state management tools, a group whose most well-known member is likely Redux.

Redux, like React itself, is a simple tool with a long learning curve if you want to build anything meaningful with it. In theory, it’s “just” a global-ish pile of data that you access via a pub/sub system with some reducers. In practice, most redux setups involve either an incredible amount of boilerplate or a homegrown system to reduce it (and so introduce a whole extra abstraction to learn).

Redux is powerful and flexible as heck, but it’s famous for its complexity.

Ok, so what do WE do?

We want to avoid the complexity of Redux (or similar) and the mess of prop drilling. But short of somehow just not having state (wouldn’t that be great?), we have to do something.

Daybreak solves this in two parts. The first is the realization that 90% of the state in most CRUD apps is so-called “Remote State” - data that came from your backend API (or some other remote service). And it turns out there are pretty great tools that focus on common patterns for dealing with remote state.

ReactQuery to the rescue

ReactQuery is a state management tool meant for data from your api. A naive way to use remote data without react query in a component might be:

const MyComponent = () => {
  const [x, setX] = useState();
  useEffect(() => (
	  fetch('example.com/api/x').then((response) => setX(response.json()))
	));
  return <div>x={x ?? 'loading...'}<div>
}

You use useEffect around a call to fetch, and put the result in component state. Simple enough.

But this pretty much skips any attempt to store the data fetched from the api - every component that needs x will need to fetch it anew. There’s also no error handling and no proper loading state.

Here’s the same thing with reactQuery:

const MyComponent = () => {
  const {data: x, isLoading, error} = useQuery(
		'someCacheKey',
	  async () => (
		  (await fetch('example.com/api/x')).json();
		)
  )
	if (error) {
		return <div>Got an error! {error}</div>;
	}
	if (loading) {
		return <div>loading...</div>;
	}
  return <div>x=#{x}<div>;
}

You can see that reactQuery gets us a loading state and error output for free, but more importantly, it also acts as an intelligent caching layer. It will only execute the query once and:

  • The cached data will be shared across other components. Any other component using useQuery and passing "someCacheKey" as a cache key will get the same data without needing to execute another fetch
  • When necessary, it’ll refetch the data in the background while still returning the stale data in the interim.
  • It has built-in request retrying
  • It’ll refetch data on window-refocus
  • You can easily customize how long before the cache becomes stale, manual invalidation, error handling, and pretty much everything else globally and/or per-query.
  • And of course, it has fantastic typescript support (omitted here to keep these code samples brief)

Now, since we don’t want to have to encode knowledge about every single api endpoint into every component that needs it, we wrap our useQuerys in custom hooks for each use:

const MyComponent = () => {
	// useGetX contains a useQuery call
  const {data: x, isLoading, error} = useGetX();
  ...
}

And so each component that needs access to the x data from the server can get it easily without having to worry about how that state is stored or managed!

But what about non-remote state?

So we’ve covered the 90% of our state that comes from our api - what about the remaining 10%?

Well, the second part of our state management solution is the realization that you can get by with just useContext as a lightweight state system. It’s not especially robust, but when you’re only storing a small handful of state… thingies… then it’s fine.

For example, one section of Daybreak’s web interface that parents use allows them to select one of several of their children if they have several enrolled in Daybreak. Once they’ve selected their child, a number of elements on several pages update to reflect their selected child.

We accomplish this by creating a SelectedChild context and provider:

const selectedChildStoreContext = createContext({});
export const useSelectedChildStore = () => useContext(selectedChildStoreContext);
const SelectedChildStore = ({children}) => {
	const [selectedChildId, setSelectedChildId] = useState();
  return <selectedChildStoreContext.Provider value={{selectedChildId, setSelectedChildId}}>
		{children}
  </selectedChildStoreContext.Provider>
};
export default SelectedChildStore;

What we’re doing here is setting up a SelectedChildStore component that we can wrap around the rest of our app near the root of our react component tree:

const App = () => {
  <div>
    <SelectedChildStore>
      <RoutesAndOtherStuff />
    </SelectedChildStore>
  </div>;
};

And then, in any component underneath that store, we have access to that shared state:

const MyComponent = () => {
  const { selectedChildId } = useSelectedChildStore();
  return <div>the currently selected child id is {selectedChildId}</div>;
};

This is accomplishing about the same thing you could do with prop drilling: you’ve got a parent component that contains some state and sub-components that need that state. But this avoids having to let every intervening component have to know about that state! If you have state in SomeComponent and want to access it in SomeSubSubSubSubSubSubComponent, you no longer need to tell SomeSubComponent about it!

What could go wrong?

Alright, so admittedly, using contexts as a state-management tool is not perfect. It’s not great if your state is more complex than a simple value. It requires some amount of boilerplate for each piece of state you want to manage and the control you have over what state changes trigger re-renders is pretty coarse.

It’s not a tool that’s entirely made for this job. If you abuse it, you can wind up with more states than route 66:

<ThingStoreA>
<ThingStoreB>
<ThingStoreC>
<ThingStoreD>
< ... >
<ThingStoreY>
<ThingStoreZ>
  <ActualComponentTree />
</ThingStoreA>
</ThingStoreB>
</ThingStoreC>
</ThingStoreD>
</ ... >
</ThingStoreY>
</ThingStoreZ>

However, if you find yourself with that problem, it’s a great indication that you’ve outgrown using useContext for state management and should be looking into a heavier-weight tool.

For our use-case, though, useContext is simple and light. We currently have only a couple pieces of state that need to be managed with contexts and so the additional weight/complexity of another tool isn’t worth it.

What might change?

We think our approach is appropriate for us right now, but our needs may change over time.

  • If we wind up with a few more bits of non-api state or if our non-api state becomes complex, we’ll probably look into a lightweight state management tool like Zustand.
  • If our overall mix of state shifts heavily towards non-api state, the value/cost tradeoff of heavier-weight tools like Redux might be worth it.
  • If we decide to migrate to a GraphQL api, there are great tools out there that combine an api client with api state management - Apollo or Relay, for example, to replace ReactQuery.

Got any preferences? Want to help us make those decisions? You can come help us make them! We’re hiring - check out our careers page!

The moral of the story

Any good technical blog post should include an exhortation for you to do things the way we do them. I guess this is a bad technical blog post because my exhortation for you is to pick tools appropriate to your use case. Use a given tool if the benefit outweighs the cost. Choose the lightest-weight solution that solves the problems you actually have and re-evaluate as your problems change.

And most importantly, come work at Daybreak!

Updated: