State Management (Context API & Libraries)
Learn advanced state management techniques using React Context API and popular libraries like Redux, Zustand, and React Query for building scalable applications.
In this lesson, we'll explore state management in React using both the built-in Context API and popular libraries. By building a small feature in our component library, you'll learn how to create and use a React Context, when to reach for external state libraries, and get hands-on with Redux Toolkit, Zustand, and React Query. (Yes, Ollie the Owl has realized state can't always live in one component!) Below is an outline of what we'll cover:
- Using React Context for global state (e.g. app theme)
- When to use Context vs external state libraries
- Setting up global state with Redux Toolkit
- Using Zustand for lightweight state management
- Managing server data with React Query
Step-by-step: build it right
Creating a global theme context with React Context
Let's start by using React Context to share state across components. Context is built into React and lets you pass values deeply without threading props through every level. It's ideal for "global" data like the current user, app theme, or locale – things many components need.
Scenario: Ollie adds a Theme toggle to the library so any component can know if the app is in light or dark mode. We'll create a ThemeContext that holds the current theme and a function to toggle it.
First, create the context and a provider component:
Loading syntax highlighting...
Here we use createContext()
to make a context, and wrap our app (or library) in ThemeProvider. The provider shares a value { theme, toggleTheme }
to all consumers.
Now any component can consume this context with the useContext
hook. For example, let's build a ThemeSwitchButton component that toggles the theme:
Loading syntax highlighting...
We call useContext(ThemeContext)
to grab the nearest provider's value. Our button reads the current theme and calls toggleTheme
on click. If we include <ThemeSwitchButton />
anywhere inside <ThemeProvider>…</ThemeProvider>
, it will reflect and change the global theme.
⚠️ Always wrap consumers in the provider.
Now multiple components can use ThemeContext. For instance, a Header component could read theme to apply dark-mode styles, while a Footer uses the same theme value. We avoided drilling a theme prop through every component, which is exactly the problem Context solves.
When to use Context vs external state libraries
React's Context API is powerful for certain situations, but it's not a silver bullet for all global state. When should you use Context, and when is it better to use an external library (like Redux)? It depends on the app's needs:
- Use Context for relatively simple or small/medium apps where you just need to share a few global values (theme, current user, etc.) and updates aren't extremely frequent. Context keeps things lightweight (no extra libraries), and is fine when not too many components or layers are involved.
- Use external libraries for complex or large apps with lots of state changes, deep updates, or advanced requirements. If you need features like time-travel debugging, undo/redo, or have performance issues with Context re-rendering many components, a library can help. Redux, for example, excels at structured state updates and debugging tools, while Zustand or Jotai provide lighter-weight optimized state management.
In short: Context is great for globally accessible data that doesn't change too often (or when you want to avoid passing props around). But as your app grows, you might find Context unwieldy or see performance bottlenecks. That's the time to introduce a dedicated state management library.
Ollie decides that for a larger feature – say a global notifications list or a shopping cart – a more structured approach would help. Next, we'll explore Redux Toolkit as a robust solution, then look at Zustand for a lighter touch, and React Query for server-side state.
Global state with Redux Toolkit
Redux is a popular state management library that gives you a single global store and predictable state updates via actions and reducers. These days, Redux is typically used with Redux Toolkit, which simplifies a lot of boilerplate. We'll use Redux Toolkit to create global state that any component can access.
Setting up Redux: First, install Redux Toolkit and React-Redux (the bindings for React):
Loading syntax highlighting...
This gives us the tools to create a store and the <Provider>
component to supply the store to React.
Let's say Ollie wants a counter available app-wide (imagine it's a notification counter or items-in-cart indicator). We'll create a Redux slice for this counter:
Loading syntax highlighting...
We used createSlice
to define state (value: 0
) and two update functions. Redux Toolkit uses Immer under the hood, so we wrote "mutating" logic (state.value += 1
) which produces an immutable update – convenient and bug-reducing. After exporting the action creators, we make a store with configureStore
and include our slice reducer.
Next, let's build a CounterDisplay component that uses this global state:
Loading syntax highlighting...
Here, useSelector
reads data from the Redux store – we select state.counter.value
. useDispatch
gives us the dispatch function to send actions. When the "+" button is clicked, we dispatch the increment()
action (from our slice). This triggers Redux to run the slice reducer and update state. Because the store is provided to React, the CounterDisplay will see the new state and re-render with the updated count.
So with Redux, any component can dispatch actions or read state, as long as it's wrapped in the <Provider store={store}>
. We've effectively lifted state to a global store:
- The counter state lives in Redux, not in any one component.
- Components like CounterDisplay (or a NavBar, etc.) can access it from anywhere.
- State changes flow predictably: component -> dispatch action -> reducer updates store -> all subscribed components re-render with new state.
⚠️ Common Redux gotchas:
Redux is powerful for large apps (time-travel debugging, middleware for async logic, etc.), but it does add complexity and boilerplate. In our small example, we had to set up a store, provider, slice, and use special hooks. For simpler cases, a lighter solution like Zustand can manage global state with far less code. Let's try that next.
Using Zustand for lightweight state management
Zustand is a minimal state management library that lets you create a global store using just React hooks. It doesn't require context providers or reducers – you define state and functions, and components directly use a custom hook to get state or call actions. This makes it very simple to set up (just import and use).
First, install Zustand in your project:
Loading syntax highlighting...
Now we can create a store. Zustand uses a create()
function to define a store. Let's replicate our counter example in Zustand:
Loading syntax highlighting...
That's it – no provider needed! The useCounterStore
we created is a hook. In CounterDisplay, we call useCounterStore(...)
with a selector function to pick what we need from state (here we grab count and the two action functions). The component re-renders automatically when count changes, and calling increment()
or decrement()
updates the state via the set
function we defined.
With Zustand, our components are independent but "magically connected" through this shared store. The simplicity is striking: to do the same with Redux we had to set up a lot, whereas with Zustand we just wrote a few lines for the store and used it directly.
A few notes on Zustand's approach:
- State updates are done by calling
set
(provided to the store callback). We ensured immutability by returning new state objects (e.g.{ count: state.count + 1 }
). - Components can subscribe to only parts of the state. In our example, we selected the whole store (
count
,increment
,decrement
), but we could also subscribe to just count alone:useCounterStore(state => state.count)
. This can optimize re-renders. - There's no need for a context provider; Zustand handles subscriptions behind the scenes. You can import and use the store hook anywhere.
Performance: Zustand is optimized to only re-render components when their subscribed state actually changes, making it very efficient.
⚠️ Gotcha: Make sure to return new objects from your
set
calls to ensure React detects the state change.
So, if Ollie needed a quick global state (say for a modal visibility or a form input shared across components) and didn't want the overhead of Redux, Zustand provides a clean solution. It's perfect for small-to-medium apps or pieces of state where Redux might be overkill.
Managing server state with React Query
So far we focused on UI state – data the user or app changes (theme, counters, etc.). But what about data from a server? For example, imagine Ollie's app needs to display a list of posts or pull user data from an API. This kind of state (often called server state) has unique challenges: fetching, caching, updating from the server, etc. You can manage it with plain Redux or Context + useEffect, but it gets complicated. React Query (officially TanStack Query) is a library built to handle server data fetching and caching for you.
Setup: Install React Query in your project:
Loading syntax highlighting...
You also need to set up a Query Client at the root of your app:
Loading syntax highlighting...
This wraps your app in a provider so that any component can use the Query hooks. Now let's use it.
Suppose we want to build a PostsList component that fetches a list of posts from an API and displays them. With React Query, we'll use the useQuery
hook:
Loading syntax highlighting...
Breaking that down: we defined fetchPosts()
which uses Axios to get some placeholder posts. Then we call useQuery
. We provide a query key (here ['posts']
– like an ID for this request) and the fetch function. useQuery
returns an object with data (we rename to posts), and status flags. React Query will automatically call fetchPosts
, manage the loading state, and store the result.
The component checks isLoading
and error
to decide what to render. If loading, we show a message; if an error occurred, we show that. Otherwise, we have our posts data, and we render the list of titles.
This approach has several advantages right out of the box:
- No manual useEffect needed:
useQuery
handles when to fetch (on mount, and you can configure refetch on window focus, etc. by default). - Built-in caching: React Query will cache the result of
['posts']
. If this component unmounts and remounts, or another component callsuseQuery
with the same key, it can return the cached data immediately (and optionally refetch in background). This means fewer network requests and a faster UI by default. - Automatic updates: If you mutate a post or add one (using React Query's
useMutation
), the library can intelligently update or invalidate the queries to keep data fresh. For example, adding a new post could refresh the['posts']
list query automatically.
In essence, React Query manages server state for us: it fetches and caches data, provides easy loading/error states, and keeps things in sync. This complements our other state management tools. You might still use Context/Redux/Zustand for client state (like user preferences or UI toggle), and use React Query for data from APIs. They can work together in the same app.
To integrate with our component library narrative: if Ollie builds a <DataTable>
or <UserList>
component that needs to fetch data, React Query can greatly simplify the code. Instead of writing useEffect with fetch and manual caching, Ollie just uses useQuery
and focuses on rendering the data.
⚠️ Don't forget the QueryClientProvider: Without wrapping your app, the hooks won't work.
query keys should be unique and stable – they're used for caching and invalidation.
Best practices & common mistakes – Avoid dumb bugs
- Avoid overusing Context: Using Context for everything can slow your app (unnecessary re-renders) and make state hard to track. Only use it for truly global data. For large state, consider a library instead.
- Don't mutate state directly: Whether in Context state or Redux, never modify objects/arrays in state without making a copy. For example, don't do
state.value++
in a normal React state update. In Redux Toolkit, you can write mutations in reducers (it uses Immer), but otherwise always update immutably. Mutable updates won't trigger re-renders in React and can cause bugs. - Wrap providers correctly: It's a common mistake to forget wrapping your app with necessary providers. If your Redux Provider or React Query QueryClientProvider is missing, none of the consumer components will work. Check that these are set up at the top level of your app.
- Use the right tool for the job: For simple state sharing, Context or a small Zustand store is fine. Don't introduce Redux (and its boilerplate) if you only have a couple of values to share. Conversely, if your state logic grows complex (lots of reducers, actions, async calls), refactor to Redux or another library sooner rather than later. It's painful to untangle state sprawl after the fact.
- Server state syncing: If you use React Query, leverage its features. For instance, use the caching and refetch behaviors instead of manually storing server data in a Redux store (one of Redux's pitfalls is trying to hand-roll caching). React Query is specialized for server data – trust it for that role.
Recap
- React Context: You learned how to create a context and provider to share state (like a theme) without prop drilling. You can now use
useContext
in any component to access global data or functions. - Context vs Libraries: We covered guidelines on when simple Context is enough and when to switch to a library for state management. You can now assess the needs of an app and choose an appropriate state solution.
- Redux Toolkit: We set up a Redux store with a slice and used
useSelector
/useDispatch
in a component. You saw how Redux provides a single source of truth and predictable updates (action -> reducer -> new state). You can now integrate Redux into a React app for complex state needs. - Zustand: We created a Zustand store in a few lines and used it directly in components, highlighting its simplicity and performance benefits. You know how to manage global state with Zustand without context providers, and how it compares to Redux in terms of boilerplate.
- React Query: Finally, you fetched data with
useQuery
and saw the advantages of built-in caching and simplified async state handling. You're able to load server data in a component with React Query and let it handle loading/error and freshness for you.
With these tools, Ollie's component library is equipped to handle state – whether it's a simple theme toggle or a complex multi-source data dashboard. You can confidently choose the right state management approach as your React projects grow.