UTA DevHub

Client State Management

Comprehensive guide for managing client state with Zustand in our React Native application

Client State Management

Overview

This document outlines our approach to managing client-side state - data that exists exclusively within the application and doesn't need to be synchronized with external servers. Our architecture implements Zustand as the dedicated solution for all client state management, following our application's Golden Rule: "Client state belongs in Zustand, not in TanStack Query or Redux". This approach optimizes for simplicity, developer experience, and performance across the application.

Purpose & Scope

  • Target Audience: React Native developers implementing features that require global state management, UI state coordination, or persistent local settings
  • Problems Addressed: Simplifying state management, reducing boilerplate, improving TypeScript integration, and providing performance optimizations
  • Scope Boundaries: Covers Zustand implementation, store patterns, performance optimization, and testing but does not cover server state management or data fetching strategies

Core Components/Architecture

Component Types

ComponentResponsibilityImplementation
Zustand StoreState container and update logicFeature-specific store files
Store ActionsMethods that modify stateDefined within store creation
SelectorsExtract and subscribe to specific state slicesUsed in components via hooks
MiddlewareAdd functionality to storesPersistence, immutability, debugging
Store HooksEncapsulate store access patternsFeature-specific custom hooks

When to Use Client State

Zustand should be used for any data that meets these criteria:

  • (Do ✅) Use for UI state like modals, drawers, and navigation state

  • (Do ✅) Use for user preferences and settings that persist across sessions

  • (Do ✅) Use for temporary form data or wizard flows

  • (Do ✅) Use for cached data that doesn't require server synchronization

  • (Do ✅) Use for cross-component communication

  • (Don't ❌) Use for server data that requires synchronization

  • (Don't ❌) Use for form state in individual components

  • (Don't ❌) Use for ephemeral UI state in single components

Design Principles

Core Architectural Principles

  • (Do ✅) Keep stores small and focused

    • Create separate stores for distinct concerns
    • Avoid monolithic stores that handle multiple domains
  • (Do ✅) Use TypeScript interfaces for store definitions

    • Define explicit types for all state and actions
    • Leverage TypeScript for autocompletion and error checking
  • (Do ✅) Create selectors for performance optimization

    • Select only the specific state slices components need
    • Use stable references for selectors when appropriate
  • (Don't ❌) Mix server and client state in the same store

    • Server state belongs in TanStack Query
    • Client state belongs in Zustand

Store Organization Patterns

PatternUsed ForImplementation
Global StoresApplication-wide settings, UI stateLocated in core/store/
Feature StoresFeature-specific stateLocated in features/[feature]/state/
Transient StoresTemporary workflow stateCreated and destroyed with features
Persistent StoresSettings, auth tokens, preferencesImplemented with persist middleware

Implementation Examples

Basic Store Structure

// core/store/uiStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
 
interface UIState {
  // State
  theme: 'light' | 'dark';
  sidebarOpen: boolean;
  activeModal: string | null;
  
  // Actions
  setTheme: (theme: 'light' | 'dark') => void;
  toggleSidebar: () => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
}
 
export const useUIStore = create<UIState>()(
  devtools(
    (set) => ({
      // Initial state
      theme: 'light',
      sidebarOpen: false,
      activeModal: null,
      
      // Actions
      setTheme: (theme) => set({ theme }),
      toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
      openModal: (modalId) => set({ activeModal: modalId }),
      closeModal: () => set({ activeModal: null }),
    }),
    {
      name: 'ui-store',
    }
  )
);
// features/auth/state/authStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
 
interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  setUser: (user: User | null) => void;
  setToken: (token: string | null) => void;
  logout: () => void;
}
 
export const useAuthStore = create<AuthState>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        token: null,
        isAuthenticated: false,
        
        setUser: (user) => set({ user, isAuthenticated: !!user }),
        setToken: (token) => set({ token }),
        logout: () => set({ user: null, token: null, isAuthenticated: false }),
      }),
      {
        name: 'auth-storage',
        storage: AsyncStorage,
        partialize: (state) => ({ token: state.token }), // Only persist token
      }
    )
  )
);

Advanced Usage Patterns

// Poor performance - re-renders on any state change
const Component = () => {
  const store = useCartStore();
  return <Text>{store.items.length}</Text>;
};
 
// Better performance - only re-renders when relevant state changes
const Component = () => {
  const itemCount = useCartStore((state) => state.items.length);
  return <Text>{itemCount}</Text>;
};
 
// Best practice - reusable selector
export const useCartItemCount = () => useCartStore((state) => state.items.length);
 
const Component = () => {
  const itemCount = useCartItemCount();
  return <Text>{itemCount}</Text>;
};
 
// Advanced - stable selector functions with shallow comparison
import { shallow } from 'zustand/shallow';
 
const Component = () => {
  const { add, remove } = useCartStore(
    (state) => ({ add: state.addItem, remove: state.removeItem }),
    shallow
  );
  
  return (
    <View>
      <Button onPress={() => add(newItem)} title="Add" />
      <Button onPress={() => remove(itemId)} title="Remove" />
    </View>
  );
};

Testing Zustand Stores

// __tests__/core/store/uiStore.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useUIStore } from '@/core/store/uiStore';
 
describe('UIStore', () => {
  // Reset store before each test
  beforeEach(() => {
    useUIStore.setState({ 
      theme: 'light',
      sidebarOpen: false,
      activeModal: null 
    });
  });
  
  it('should toggle sidebar', () => {
    const { result } = renderHook(() => useUIStore());
    
    expect(result.current.sidebarOpen).toBe(false);
    
    act(() => {
      result.current.toggleSidebar();
    });
    
    expect(result.current.sidebarOpen).toBe(true);
  });
  
  it('should set theme', () => {
    const { result } = renderHook(() => useUIStore());
    
    expect(result.current.theme).toBe('light');
    
    act(() => {
      result.current.setTheme('dark');
    });
    
    expect(result.current.theme).toBe('dark');
  });
});

Troubleshooting