Zustand: State Management in React Native
Whilst React Native has evolved and stabilised over the years, the ways in which data can be handled within an application have continued to expand - with a range of libraries and frameworks offering a variety of solutions for handling and persisting data. The prevalence of Redux, and all of its variations and flavours covering persistence and offline handling, may suggest a consensus. However as always, there's more nuance - and rather than a one-size-fits-all solution, there should be a degree of thought into:
- the type of data you're looking to store - is it large arrays of structured data, some simple key/value object pairs, or some binary data?
- how long you're looking to store it for - should it be ephemeral (removed once the app has closed) or should it persist across sessions?
- how sensitive is the data - does it need additional encryption at rest, or does it need to be stored in the keychain
- if there will be any duplication of data - if the same data can occur within multiple points, a database could be a good choice
- should the data be available throughout the app - do you only need it on one screen, or should it be available for the entire app?
Whilst there are many high-quality, well-maintained, and impressively well documented frameworks that handle these cases, as part of a new project I wanted to try a different approach to state management, and explore a new framework that I'd recently discovered: Zustand.
"What is Zustand?"
Zustand is an adaptable barebones state-management framework, with an API based on hooks.
In general, when hooks are used within components, an update in one component does not cause other usages of the hook to update.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
const useValueStore = () => { const [value, setValue] = useState(0) const increment = useCallback(() => { setValue(prevValue => prevValue + 1); }, []) return { value, increment } }l const ComponentOne = () => { const { value } = useValueStore(); return <Text>{value}</Text>; } const ComponentTwo = () => { const { increment } = useCustomHook(); return <TouchableOpacity onPress={increment}>{...}</TouchableOpacity>; }
When ComponentTwo's hook updates, ComponentOne's hook will not reflect those changes - the value will stay as 0.
However, when creating and using a Zustand hook, an update in one component will be reflected across others:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
import { create } from 'zustand'; const useIncrementingStore = create((set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })) })) const ComponentOne = () => { const value = useIncrementingStore((state) => state.value); return <Text>{value}</Text>; } const ComponentTwo = () => { const increment = useIncrementingStore((state) => state.increment); return <TouchableOpacity onPress={increment}>{...}</TouchableOpacity>; }
In this example, an update in ComponentTwo will cause the value in ComponentOne to update.
Whilst this is a straightforward example, this is all we need to do to start integrating Zustand into our projects! There's no need to wrap components in context providers, and no need to add additional boilerplate code. We can write a basic hook, and start gaining the benefits that Zustand has to offer. Of course, as our projects grow, the requirements we have of Zustand increase - but it handles these with ease.
"What about async actions?"
There are occasions where we'll need to handle requests that take time to complete. In JavaScript, we can write a function that will 'wait' for the request to complete before continuing onwards. These are known as 'async functions'. In some state management libraries, handling these requires additional middleware.
In Zustand however, async functions are supported without the need for additional middleware. Once your async function has completed, call set(...) with your response.
1 2 3 4 5 6 7 8 9
import { create } from 'zustand'; const useCarStore = create((set) => ({ cars: [], fetch: async (garage) => { const response = await fetch(garage) set({ cars: await response.json() }) } }))
It really is that simple! We may want to expand on this, by setting a 'fetching' property in state as true when the request starts, and setting it to false once the request has completed. This would allow us to show a loading state. Furthermore, by catching errors thrown from the fetch(...) operation and including an 'error' property in the store, it would be possible to store and display any errors that may have occurred whilst executing the async function.
"What if I'm working on a legacy project, without functional components?"
Functional components are JavaScript functions that return JSX - a structure similar to HTML that allows us to return components for rendering.
However, in older projects, class components are much more common. These are classes that extend React.Component, and they must contain a render() function that returns the JSX. Within these classes, you cannot directly use hooks. Whilst it may be tempting to rewrite the class component to allow us to use hooks there are many reasons why that may not be possible, or why it may not be wise to make larger changes as part of adding Zustand.
One of the benefits of Zustand is that whilst by default the store is a hook, you can still access & mutate it outside of a functional component, and even outside of components entirely! Zustand makes it possible to access state and subscribe to updates outside of a functional component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
import { create } from 'zustand'; const useIncrementingStore = create((set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })) })) const value = useIncrementingStore.getState().value const unsubscribe = useIncrementingStore.subscribe(console.log) useIncrementingStore.setState({ value: 1 }) unsubscribe()
This can be enhanced via the use of selector middleware, that allows you to subscribe to only a given slice of your store. This would allow components to only receive updates for the data they are needing, reducing the need for re-rendering.
"What if I want to persist data?"
Many projects have the need to persist data between sessions. By persisting data, the networking load can be reduced and app launch time can be improved. For these cases, Zustand provides persistence middleware. This lets us persist the data using any storage we choose - in this example, AsyncStorage is used.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' const useIncrementingStore = create( persist( (set, get) => ({ value: 0, increment: () => set({ value: get().value + 1 }) }), { name: 'incrementing-storage', storage: createJSONStorage(() => AsyncStorage), } ) )
Whilst persistence is easy, we should pay thought to whether:
- persistence is even required - there may be data that becomes instantly 'stale' and would always require fetching before display (for example, a bank balance or stock price)
- the data can be safely persisted - it may require additional encryption that AsyncStorage does not provide
In conclusion...
Zustand is a great state-management solution that is quick to learn and easily implementable with low impact to an existing codebase. Beyond what we've discussed today Zustand is capable of so much more, and whilst it's possible to delve into more advanced functionality and usage, Zustand doesn't require us to learn this upfront. Large changes also aren't required, reducing the time and effort required to trial it:
- we don't need to wrap the app in an additional context provider, potentially breaking existing test suites
- we don't need to refactor complex class components into functional components - we can access Zustand regardless, and tackle the refactor on it's own
- we don't need to migrate the enter app's data store to use Zustand - slices can be migrated individually
Ultimately, we should still evaluate the needs of our project and the types of data being stored, and choose the right framework for the job. For example, we should not use a an AsyncStorage-persisted Zustand store to hold sensitive information - the OS Keychain is a more appropriate location. We should also not use Zustand for large amounts of consistently structured information - databases are a more appropriate solution.
To learn more about Zustand, check out the Github repo here.
Next...
Looking for more to read? Check out our other posts here!
Want to check out our other projects? Find them here!
Got an idea for a new app to improve our world? Say hi here!
Found this post helpful? Follow the author on LinkedIn!