HomeBlogZustand State Management in the Animeversum app

Zustand State Management in the Animeversum app

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

3/17/2025 • 640 views

Zustand State Management in the Animeversum app

Introduction

When building the Animeversum application, I faced significant challenges managing state across components. I was struggling with state persistence for filtered data using React's Context API. The problem was complex because I wasn't working with a database but rather an external API with endpoints returning different data structures.

This created a situation where either the caching was blocking fresh results, or I couldn't correctly persist data because rendered results were interfering with new requests. I needed a simpler solution and chose Zustand. Initially, I encountered some difficulties, but eventually successfully managed to persist all states for results, selected filters, and pages while still using server cache without blocking any functionality. As a bonus, my code became simpler, cleaner, and shorter.

What is Zustand?

Zustand is a lightweight state management library for React applications. Unlike more complex alternatives, Zustand offers a straightforward approach to sharing and persisting state across components. It requires minimal setup and integrates seamlessly with TypeScript.


Store Structure

The Animeversum application uses a central store to manage all shared state:

typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Genre } from '../hooks/useGenreFilter';
import { Creator } from '../hooks/useCreatorFilter';
import { Studio } from '../hooks/useStudioFilter';

export const useAnimeStore = create<AnimeFilterState>()(
  persist(
    (set) => ({
      // State and actions
      searchQuery: '',
      currentPage: 1,
      selectedGenres: [],
      setSearchQuery: (query) => set({ searchQuery: query }),
      resetFilters: () => set(initialState),
      // ...more state and actions
    }),
    {
      name: 'anime-filters',
      partialize: (state) => ({ ...state }),
    }
  )
);

This store holds:

  • Search parameters (query, current page)
  • Filter selections (genres, creators, studios)
  • Display preferences (TV shows, movies, content filtering)
  • Actions to update these values

How Zustand Works

Zustand creates a store that components can access through a custom hook. When a component calls useAnimeStore(), it gets the current state values and functions to update them:

typescript
const {
  searchQuery,
  currentPage,
  selectedGenres,
  setCurrentPage,
  setSearchQuery
} = useAnimeStore();


When state changes, only components that use the specific updated values will re-render, making the application more efficient.


State Persistence

One of Zustand's key features is built-in persistence. The persist middleware automatically saves the state to localStorage:

typescript
persist(
  (set) => ({ /* store implementation */ }),
  {
    name: 'anime-filters',
    partialize: (state) => ({ ...state }),
  }
)

This means when users navigate away and return to the app, their filters and search parameters remain intact.

Implementation

The “Explore Anime” component demonstrates how Zustand simplifies complex state management:

Accessing store values:

typescript
const {
  searchQuery,
  currentPage,
  selectedGenres,
  // ...other values
} = useAnimeStore();

Initializing from persisted state:

typescript
useEffect(() => {
  if (searchQuery && window.location.hash === '#exploreAnime') {
    setCurrentSearchQuery(searchQuery);
    setShouldSearch(true);
  }
}, []);

Building API requests from store state:

typescript
const params = {
  page: page || currentPage,
  limit: 25,
  sort: scoredBySort || 'desc',
  // ...other parameters
};

if (selectedGenres.length > 0) {
  params.genres = selectedGenres.map(g => g.id).join(',');
}

Balancing Local and Global State

The "Explore Anime" component uses both Zustand for shared state and React's useState for component-specific concerns:

typescript
// Store state (shared across components)
const { searchQuery, currentPage } = useAnimeStore();
typescript
// Local component state
const [animeList, setAnimeList] = useState<Anime[]>([]);
const [isSearching, setIsSearching] = useState(false);

This approach keeps shared state in Zustand while maintaining component-specific state locally. I'm talking about a state that only matters to a single component and doesn't need to be shared with others.

There are several good reasons to maintain this separation:

  1. Performance - Every change to global state potentially triggers re-renders across multiple components. Keeping UI-specific state local minimizes unnecessary re-renders.
  2. Simplicity - The global store stays focused on data that actually needs to be shared, making it easier to understand and maintain.
  3. Isolation - If a component has a bug related to its local state, that bug is contained within that component, not affecting other parts of the application.
  4. Memory efficiency - Unmounting a component with local state automatically cleans up that state, while global state persists.

Practical Example

In the “Anime Explorer”:

  • When a user types into the search box, this is immediately stored in global state (searchQuery) because other components might need this information, and it should persist between page visits.
  • When search results arrive from the API, they're stored in local state (animeList) because:
    • They're derived directly from the current filters and search
    • No other components need direct access to these results
    • If the user navigates away and comes back, the app should re-fetch fresh results anyway
  • The loading indicator state (isSearching) is kept local because only this component needs to know if it's currently fetching data.

Filter Synchronization

When filters change, the component updates its behavior:

typescript
useEffect(() => {
  if (!isInitialSortRender.current) {
    if (selectedGenres.length > 0 || selectedCreators.length > 0) {
      setCurrentPage(1);
    }
    setShouldSearch(true);
  } else {
    isInitialSortRender.current = false;
  }
}, [selectedGenres, selectedCreators, selectedStudios]);


This ensures that when users change filters, the page resets and a new search occurs.

Benefits of Zustand in Animeversum

  1. Simplified State Management: The code is cleaner and more maintainable without complex reducers or action types.
  2. Automatic Persistence: User preferences and filters remain across sessions without additional code.
  3. Performance Optimization: Components only re-render when their specific state values change.
  4. Type Safety: TypeScript integration provides better developer experience and fewer bugs.
  5. Easier Debugging: State changes are more predictable and easier to track.

Summary

Implementing Zustand in the Animeversum application solved the challenging problem of state persistence while working with an external API and server caching. It provided a simpler alternative to Context API, allowing for cleaner code and better user experience.

The centralized store maintains search parameters, filters, and pagination state, ensuring that users can navigate away and return to find their search results and filters intact. This pattern works particularly well for applications that need to maintain complex state across multiple components while keeping the implementation straightforward and maintainable while significantly improving the user experience.


You can check how it works in practice under URL:

https://animeversum.netlify.app/genres

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

Author