← Back to Blog

How to Pass Initial Props to Zustand State Without Losing Your Mind

12/11/2024·Matija Žiberna·
Coding
How to Pass Initial Props to Zustand State Without Losing Your Mind

When building applications with Zustand, managing state tied to dynamic component props can become surprisingly tricky. Recently, I faced a frustrating challenge: initializing a Zustand store with a brandId filter for product tables while maintaining clean and efficient state management.

This article walks you through the problem, highlights common pitfalls, and shows the ultimate solution.

The Problem: Managing State with Dynamic Props

Imagine you’re building a product table with filter options, including a mandatory brandId. The API expects this filter in every query, so it must always be included in the state.

Seems simple, right? Well, not quite.

Here are the challenges we encountered

Initialization Issues

The store is created before we have access to brandId from the component props. This makes it impossible to set up the initial state properly.

State Persistence Conflicts

The store uses sessionStorage to persist state, but this causes issues when navigating between routes with different filters. Multiple table instances unintentionally share the same persisted state.

Re-render Problems

Using useEffect to set the initial filter after component mount leads to unnecessary re-renders and potential race conditions.

Prop Dependency Challenges

There’s no clean way to update the store when props (like brandId) change. This results in stale data and complex workarounds.

The Frustration

  • If brandId isn’t properly handled, API calls fail silently or return incorrect data.
  • Forgetting to include brandId when updating filters is an easy and common mistake.
  • The workaround code often becomes overly complex and harder to maintain.

These challenges turn a seemingly straightforward requirement into a headache.

The Solution: Combining React Context and Zustand

To solve these issues, a pattern that combines React Context with Zustand solves this issue. This approach allows us to:

Pass initial props (like brandId) cleanly to the store.

Avoid unnecessary re-renders.

Maintain persistent state with localStorage or sessionStorage.

Keep the codebase clean, type-safe, and easy to maintain.

<ProductTableConfigProvider filters={{ brandIds: [brandId] }}> <ProductsTable brandId={brandId} /> </ProductTableConfigProvider>

Passing the filter to the provider will set the filter prop on initialization of the Zustand store.

Architecture Overview

Here’s the high-level approach:

Store Creation: Use Zustand to define a store with customizable initial props and a persistent state.

Context Setup: Create a React Context to hold the store instance, ensuring type safety and easy access.

Provider Pattern: Use a provider component to pass initial props and make the store available to the component tree (above snippet).

Creating the Zustand Store

Let’s start by creating the store. This store is basic; the only exception is that the function accepts the initProps, which can be anything that you would like to pass to the store on initialization.

import { createJSONStorage, persist } from "zustand/middleware";import { createStore } from "zustand";export const createProductTableConfigStore = (initProps: any) => { return createStore( persist( (set, get) => ({ filters: {}, page: 1, limit: 10, sort: [], ...initProps, // Passed in initProps (it can be anything) setFilters: (filters) => set({ filters }), setPage: (page) => set({ page }), setLimit: (limit) => set({ limit }), }), { name: "product-table-config", storage: createJSONStorage(() => sessionStorage), } ) );};

Notice how we are spreading …initProps on the object

Creating the Context

In a separate file, connect a context to the store created in the previous step.

import { createContext, useContext, useRef } from "react";import { createProductTableConfigStore } from "./store";const ProductTableConfigContext = createContext(null);export function ProductTableConfigProvider({ children, ...props }) { const storeRef = useRef(); if (!storeRef.current) { storeRef.current = createProductTableConfigStore(props); } return ( <ProductTableConfigContext.Provider value={storeRef.current}> {children} </ProductTableConfigContext.Provider> );}export const useProductTableConfig = () => { const context = useContext(ProductTableConfigContext); if (!context) { throw new Error( "useProductTableConfig must be used within a ProductTableConfigProvider" ); } return context;};

Usage Example

As seen before. Wrap your component tree with the ProductTableConfigProvider and pass initial props:

<ProductTableConfigProvider filters={{ brandId: "123" }}> <ProductTable /></ProductTableConfigProvider>

Inside ProductTable, you can access and interact with the store using the custom hook:

const productTableConfig = useProductTableConfig();const filters = productTableConfig((state) => const getQueryString = useProductTableConfig(state => state.getQueryString)const { products, metadata } = useProducts({ queryString: getQueryString() })

In my case, I call my custom hook useProduct that fetches data from API with a get call that is built based on the filter that now includes brandId passed-in via initProp.

Benefits of This Approach

  • Prevented Re-Renders: The store instance is created once and persists across renders.
  • Centralized Configuration: All state logic is managed in the store, not scattered across components.
  • Persistent State: Data persists seamlessly with localStorage or sessionStorage.
  • Customizable Initial State: You can pass initial props (like brandId) directly.

Conclusion

Managing dynamic props in Zustand can be a pain, but combining React Context with Zustand’s powerful state management makes it manageable. With this pattern, you’ll eliminate re-renders, ensure clean initialization, and avoid race conditions.

Thanks,
Matija