UTA DevHub
Guides

State Management Implementation Guide

Patterns for implementing client state management using Zustand.

GUIDE-02: State Management Implementation Guide

Overview

This guide provides practical implementation patterns for managing client state using Zustand, a lightweight and efficient state management library for React Native applications. It covers store creation, usage in components, handling asynchronous logic, persistence, and best practices within the context of our application architecture.

When To Use

Use these patterns when managing state that is:

  • Global: Accessible and potentially modified by multiple parts of the application (e.g., UI state, user preferences).
  • Client-Side: Not directly tied to server data (e.g., modal visibility, theme settings).
  • Synchronous: Changes are typically immediate and don't involve network requests (though async logic can be handled within stores).
  • Persistent (Optional): Needs to survive app restarts (e.g., theme preference) using middleware like persist.

Important: Server state (e.g., API data) should be managed by TanStack Query (GUIDE-01), not Zustand.

Implementation Patterns

1. Store Creation

Zustand stores are created using the create function, defining state and methods to update it in a single object. Each feature typically has its own store for modularity.

// src/features/settings/state/useSettingsStore.ts
import { create } from 'zustand';
 
type ThemeMode = 'light' | 'dark' | 'system';
 
interface SettingsState {
  theme: ThemeMode;
  language: string;
  notificationsEnabled: boolean;
  setTheme: (theme: ThemeMode) => void;
  setLanguage: (language: string) => void;
  toggleNotifications: () => void;
  resetSettings: () => void;
}
 
export const useSettingsStore = create<SettingsState>((set) => ({
  theme: 'system',
  language: 'en',
  notificationsEnabled: true,
  setTheme: (theme) => set({ theme }),
  setLanguage: (language) => set({ language }),
  toggleNotifications: () => set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
  resetSettings: () => set({ theme: 'system', language: 'en', notificationsEnabled: true }),
}));
  • Key Concepts:
    • The create function returns a hook (e.g., useSettingsStore) for accessing state and methods
    • Stores are feature-specific, aligning with the modular architecture
    • Use TypeScript interfaces for type safety

2. Using Stores in Components

Access and update state using the store's hook in React components.

// src/features/settings/components/ThemeSelector.tsx
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useSettingsStore } from '../state/useSettingsStore';
 
type ThemeMode = 'light' | 'dark' | 'system';
 
export const ThemeSelector = () => {
  const { theme, setTheme } = useSettingsStore();
 
  const handleThemeChange = (newTheme: ThemeMode) => {
    setTheme(newTheme);
  };
 
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Select Theme:</Text>
      <View style={styles.optionsContainer}>
        <ThemeOption 
          label="Light" 
          selected={theme === 'light'} 
          onPress={() => handleThemeChange('light')} 
        />
        <ThemeOption 
          label="Dark" 
          selected={theme === 'dark'} 
          onPress={() => handleThemeChange('dark')} 
        />
        <ThemeOption 
          label="System" 
          selected={theme === 'system'} 
          onPress={() => handleThemeChange('system')} 
        />
      </View>
    </View>
  );
};
 
interface ThemeOptionProps {
  label: string;
  selected: boolean;
  onPress: () => void;
}
 
const ThemeOption = ({ label, selected, onPress }: ThemeOptionProps) => (
  <TouchableOpacity
    style={[styles.option, selected && styles.selectedOption]}
    onPress={onPress}
  >
    <Text style={[styles.optionText, selected && styles.selectedText]}>{label}</Text>
  </TouchableOpacity>
);
 
const styles = StyleSheet.create({
  container: {
    padding: 16,
  },
  title: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 16,
  },
  optionsContainer: {
    flexDirection: 'row',
    justifyContent: 'space-between',
  },
  option: {
    flex: 1,
    padding: 12,
    backgroundColor: '#f0f0f0',
    borderRadius: 8,
    alignItems: 'center',
    marginHorizontal: 4,
  },
  selectedOption: {
    backgroundColor: '#007AFF',
  },
  optionText: {
    fontWeight: '500',
  },
  selectedText: {
    color: 'white',
  },
});
  • Key Concepts:
    • The store hook provides direct access to state and methods, simplifying component logic
    • Components re-render only when subscribed state changes, optimizing performance

3. Asynchronous Logic (Use Sparingly)

While TanStack Query handles server-side async operations, Zustand can manage client-side async logic within store methods.

Example: Simulating a complex client-side task.

// src/features/complexProcess/state/useProcessingStore.ts
import { create } from 'zustand';
 
// Simulated async task
const simulateComplexClientTask = async (input: string): Promise<string> => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
  if (input === 'fail') throw new Error('Client task failed');
  return `Processed: ${input}`;
};
 
interface ProcessingState {
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  result: string | null;
  error: string | null;
  processData: (input: string) => Promise<void>;
  reset: () => void;
}
 
export const useProcessingStore = create<ProcessingState>((set) => ({
  status: 'idle',
  result: null,
  error: null,
  processData: async (input) => {
    set({ status: 'loading', error: null });
    try {
      const result = await simulateComplexClientTask(input);
      set({ status: 'succeeded', result });
    } catch (error: any) {
      set({ status: 'failed', error: error.message });
    }
  },
  reset: () => set({ status: 'idle', result: null, error: null }),
}));
// Usage in a component
import React from 'react';
import { View, Button, Text } from 'react-native';
import { useProcessingStore } from '../state/useProcessingStore';
 
const ProcessingComponent = () => {
  const { status, result, error, processData, reset } = useProcessingStore();
 
  return (
    <View>
      <Button
        title="Start Processing"
        onPress={() => processData('some input')}
        disabled={status === 'loading'}
      />
      {status === 'loading' && <Text>Processing...</Text>}
      {status === 'succeeded' && <Text>Success: {result}</Text>}
      {status === 'failed' && <Text>Error: {error}</Text>}
      <Button title="Reset" onPress={reset} />
    </View>
  );
};
  • Recommendation: Avoid using Zustand for server-side async operations; use TanStack Query instead.

4. Selectors for Optimized Access

Client State Management supports optimized component rendering through selectors, allowing components to subscribe to specific state slices.

// src/features/settings/state/settingsSelectors.ts
import { useSettingsStore } from './useSettingsStore';
 
export const useTheme = () => useSettingsStore((state) => state.theme);
export const useLanguage = () => useSettingsStore((state) => state.language);
export const useNotificationsEnabled = () => useSettingsStore((state) => state.notificationsEnabled);
// Usage in a component
import React from 'react';
import { View, Text } from 'react-native';
import { useTheme, useLanguage } from '../state/settingsSelectors';
 
const SettingsDisplay = () => {
  const theme = useTheme();
  const language = useLanguage();
 
  return (
    <View>
      <Text>Theme: {theme}</Text>
      <Text>Language: {language}</Text>
    </View>
  );
};
  • Benefit: Selectors ensure components only re-render when the selected state changes, improving performance

5. Persistence with Middleware

To persist state across app restarts, use the persist middleware with AsyncStorage.

// src/features/settings/state/useSettingsStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
 
type ThemeMode = 'light' | 'dark' | 'system';
 
interface SettingsState {
  theme: ThemeMode;
  language: string;
  notificationsEnabled: boolean;
  setTheme: (theme: ThemeMode) => void;
  setLanguage: (language: string) => void;
  toggleNotifications: () => void;
  resetSettings: () => void;
}
 
export const useSettingsStore = create(
  persist<SettingsState>(
    (set) => ({
      theme: 'system',
      language: 'en',
      notificationsEnabled: true,
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleNotifications: () => set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
      resetSettings: () => set({ theme: 'system', language: 'en', notificationsEnabled: true }),
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({ theme: state.theme, language: state.language }),
    }
  )
);
  • Key Concepts:
    • The persist middleware saves state to AsyncStorage
    • Use partialize to specify which parts of the state to persist

Common Challenges

  • Multiple Stores: Organize multiple stores by feature (e.g., useSettingsStore, useUiStore) for clarity
  • State Mutations: Always use set to update state; direct mutations won't trigger re-renders or persist changes
  • Overusing Stores: Avoid storing server state in Zustand; use TanStack Query for API data

Performance Considerations

  • Fine-Grained Subscriptions: Use selectors to subscribe to specific state slices, reducing unnecessary re-renders
  • Store Scope: Keep stores focused on specific domains to avoid large, complex state objects
  • Middleware Overhead: Be cautious with middleware like persist, as it may impact performance for large states

Examples

Managing UI State (Modal Visibility)

// src/features/ui/state/useUiStore.ts
import { create } from 'zustand';
 
interface UiState {
  isSidebarOpen: boolean;
  activeModal: string | null;
  openSidebar: () => void;
  closeSidebar: () => void;
  toggleSidebar: () => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
}
 
export const useUiStore = create<UiState>((set) => ({
  isSidebarOpen: false,
  activeModal: null,
  openSidebar: () => set({ isSidebarOpen: true }),
  closeSidebar: () => set({ isSidebarOpen: false }),
  toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
  openModal: (modalId) => set({ activeModal: modalId }),
  closeModal: () => set({ activeModal: null }),
}));
// src/features/ui/components/ModalManager.tsx
import React from 'react';
import { View, Button } from 'react-native';
import { useUiStore } from '../state/useUiStore';
import { SomeModalComponent } from './SomeModalComponent';
 
export const ModalManager = () => {
  const { activeModal, openModal, closeModal } = useUiStore();
 
  return (
    <View>
      <Button title="Open Modal" onPress={() => openModal('myModal')} />
      {activeModal === 'myModal' && (
        <SomeModalComponent isVisible={true} onClose={closeModal} />
      )}
    </View>
  );
};

Best Practices

  • (Do ✅) Use multiple feature-specific stores rather than one large global store
  • (Do ✅) Leverage selectors for optimized component rendering
  • (Don't ❌) Store server state or API responses in Zustand
  • (Do ✅) Use TypeScript for type safety
  • (Consider 🤔) Isolating store logic into separate files for better organization