Skip to content

State Management

PGRestify provides comprehensive state management solutions for React applications, offering both built-in state management and integration with popular external libraries. This guide covers local state management, global state coordination, and best practices for handling server state.

Overview

State management in PGRestify React applications involves several layers:

  • Server State: Data from your PostgreSQL database via PostgREST
  • Client State: Local UI state and user interactions
  • Cache State: Query results and optimistic updates
  • Form State: Input values and validation states

Built-in State Management

Query State Management

PGRestify hooks automatically manage query state:

typescript
import { useQuery } from '@webcoded/pgrestify/react';

function UserList() {
  const { 
    data, 
    isLoading, 
    isError, 
    error, 
    refetch 
  } = useQuery('users', (client) => 
    client.from('users').select('*')
  );

  // State is automatically managed:
  // - Loading states
  // - Error handling
  // - Data caching
  // - Refetch capabilities

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data?.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
      <button onClick={() => refetch()}>Refresh</button>
    </div>
  );
}

Mutation State Management

Mutations provide comprehensive state tracking:

typescript
import { useMutation, useQueryClient } from '@webcoded/pgrestify/react';

function CreateUserForm() {
  const queryClient = useQueryClient();
  
  const createUser = useMutation({
    mutationFn: (userData) => 
      client.from('users').insert(userData).select().single(),
    onSuccess: (newUser) => {
      // Update cache automatically
      queryClient.setQueryData(['users'], (old) => [...(old || []), newUser]);
    },
    onError: (error) => {
      console.error('Failed to create user:', error);
    },
    onSettled: () => {
      // Always runs after success or error
      console.log('Mutation completed');
    }
  });

  const handleSubmit = (formData) => {
    createUser.mutate(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <button 
        type="submit" 
        disabled={createUser.isLoading}
      >
        {createUser.isLoading ? 'Creating...' : 'Create User'}
      </button>
      {createUser.isError && (
        <div className="error">
          Error: {createUser.error.message}
        </div>
      )}
    </form>
  );
}

Advanced State Patterns

Global State with Context

Create a global state provider for shared data:

typescript
// contexts/AppStateContext.tsx
import React, { createContext, useContext, useReducer } from 'react';

interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  filters: Record<string, unknown>;
  notifications: Notification[];
}

interface AppStateContextType {
  state: AppState;
  dispatch: React.Dispatch<AppStateAction>;
}

const AppStateContext = createContext<AppStateContextType | undefined>(undefined);

type AppStateAction = 
  | { type: 'SET_USER'; payload: User }
  | { type: 'SET_THEME'; payload: 'light' | 'dark' }
  | { type: 'SET_FILTERS'; payload: Record<string, unknown> }
  | { type: 'ADD_NOTIFICATION'; payload: Notification }
  | { type: 'REMOVE_NOTIFICATION'; payload: string };

function appStateReducer(state: AppState, action: AppStateAction): AppState {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'SET_FILTERS':
      return { ...state, filters: action.payload };
    case 'ADD_NOTIFICATION':
      return { 
        ...state, 
        notifications: [...state.notifications, action.payload] 
      };
    case 'REMOVE_NOTIFICATION':
      return {
        ...state,
        notifications: state.notifications.filter(n => n.id !== action.payload)
      };
    default:
      return state;
  }
}

export function AppStateProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(appStateReducer, {
    user: null,
    theme: 'light',
    filters: {},
    notifications: []
  });

  return (
    <AppStateContext.Provider value={{ state, dispatch }}>
      {children}
    </AppStateContext.Provider>
  );
}

export function useAppState() {
  const context = useContext(AppStateContext);
  if (!context) {
    throw new Error('useAppState must be used within AppStateProvider');
  }
  return context;
}

Combined Server and Client State

Integrate server state with global client state:

typescript
// hooks/useUserWithPreferences.ts
import { useQuery } from '@webcoded/pgrestify/react';
import { useAppState } from '../contexts/AppStateContext';

export function useUserWithPreferences(userId: string) {
  const { state, dispatch } = useAppState();
  
  const { data: user, ...queryState } = useQuery(
    ['user', userId],
    (client) => client.from('users').select('*').eq('id', userId).single()
  );
  
  const { data: preferences } = useQuery(
    ['user-preferences', userId],
    (client) => client.from('user_preferences').select('*').eq('user_id', userId).single(),
    { enabled: !!user }
  );
  
  // Sync server state with global state
  React.useEffect(() => {
    if (user && user.id === state.user?.id) {
      dispatch({ type: 'SET_USER', payload: user });
    }
  }, [user, dispatch, state.user?.id]);
  
  // Apply preferences to global theme
  React.useEffect(() => {
    if (preferences?.theme && preferences.theme !== state.theme) {
      dispatch({ type: 'SET_THEME', payload: preferences.theme });
    }
  }, [preferences, dispatch, state.theme]);
  
  return {
    user,
    preferences,
    ...queryState
  };
}

State Synchronization

Real-time State Updates

Sync state with real-time database changes:

typescript
// hooks/useRealtimeState.ts
import { useQuery, useQueryClient } from '@webcoded/pgrestify/react';
import { useRealtime } from '@webcoded/pgrestify/react';

export function useRealtimeUserList() {
  const queryClient = useQueryClient();
  
  // Initial query
  const queryResult = useQuery('users', (client) =>
    client.from('users').select('*')
  );
  
  // Real-time subscription
  useRealtime('users', {
    event: '*',
    schema: 'public',
    table: 'users'
  }, (payload) => {
    const { eventType, new: newRecord, old: oldRecord } = payload;
    
    queryClient.setQueryData(['users'], (oldData: User[] = []) => {
      switch (eventType) {
        case 'INSERT':
          return [...oldData, newRecord as User];
        case 'UPDATE':
          return oldData.map(user => 
            user.id === newRecord.id ? newRecord as User : user
          );
        case 'DELETE':
          return oldData.filter(user => user.id !== oldRecord.id);
        default:
          return oldData;
      }
    });
  });
  
  return queryResult;
}

Cross-Component State Sharing

Share state between components using custom hooks:

typescript
// hooks/useSharedFilters.ts
import { useQueryClient } from '@webcoded/pgrestify/react';
import { useAppState } from '../contexts/AppStateContext';

export function useSharedFilters() {
  const { state, dispatch } = useAppState();
  const queryClient = useQueryClient();
  
  const setFilters = (filters: Record<string, unknown>) => {
    dispatch({ type: 'SET_FILTERS', payload: filters });
    
    // Invalidate related queries when filters change
    queryClient.invalidateQueries(['filtered-data']);
  };
  
  const clearFilters = () => {
    dispatch({ type: 'SET_FILTERS', payload: {} });
    queryClient.invalidateQueries(['filtered-data']);
  };
  
  return {
    filters: state.filters,
    setFilters,
    clearFilters
  };
}

// Usage in components
function FilterBar() {
  const { filters, setFilters } = useSharedFilters();
  
  return (
    <div>
      <input 
        value={filters.search || ''}
        onChange={(e) => setFilters({ ...filters, search: e.target.value })}
      />
      <select 
        value={filters.category || ''}
        onChange={(e) => setFilters({ ...filters, category: e.target.value })}
      >
        <option value="">All Categories</option>
        <option value="tech">Technology</option>
        <option value="design">Design</option>
      </select>
    </div>
  );
}

function FilteredList() {
  const { filters } = useSharedFilters();
  
  const { data } = useQuery(
    ['filtered-data', filters],
    (client) => {
      let query = client.from('posts').select('*');
      
      if (filters.search) {
        query = query.textSearch('title', filters.search);
      }
      
      if (filters.category) {
        query = query.eq('category', filters.category);
      }
      
      return query;
    }
  );
  
  return (
    <div>
      {data?.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

Form State Management

Controlled Forms with Validation

typescript
// hooks/useFormState.ts
import { useState, useCallback } from 'react';
import { useMutation, useQueryClient } from '@webcoded/pgrestify/react';

interface UseFormStateOptions<T> {
  initialValues: T;
  validationSchema?: (values: T) => Record<string, string>;
  onSubmit: (values: T) => Promise<unknown>;
}

export function useFormState<T extends Record<string, unknown>>({
  initialValues,
  validationSchema,
  onSubmit
}: UseFormStateOptions<T>) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});
  
  const mutation = useMutation({
    mutationFn: onSubmit,
    onSuccess: () => {
      setValues(initialValues);
      setErrors({});
      setTouched({});
    },
    onError: (error: any) => {
      if (error.details) {
        setErrors(error.details);
      }
    }
  });
  
  const setValue = useCallback((field: keyof T, value: unknown) => {
    setValues(prev => ({ ...prev, [field]: value }));
    setTouched(prev => ({ ...prev, [field]: true }));
    
    // Clear error when user starts typing
    if (errors[field as string]) {
      setErrors(prev => ({ ...prev, [field]: undefined }));
    }
  }, [errors]);
  
  const validate = useCallback(() => {
    if (!validationSchema) return true;
    
    const validationErrors = validationSchema(values);
    setErrors(validationErrors);
    
    return Object.keys(validationErrors).length === 0;
  }, [values, validationSchema]);
  
  const handleSubmit = useCallback((e?: React.FormEvent) => {
    e?.preventDefault();
    
    if (!validate()) {
      return;
    }
    
    mutation.mutate(values);
  }, [values, validate, mutation]);
  
  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    mutation.reset();
  }, [initialValues, mutation]);
  
  return {
    values,
    errors,
    touched,
    setValue,
    handleSubmit,
    reset,
    isSubmitting: mutation.isLoading,
    isSubmitted: mutation.isSuccess
  };
}

// Usage
function UserForm({ user, onSave }: { user?: User; onSave: (user: User) => Promise<User> }) {
  const form = useFormState({
    initialValues: user || { name: '', email: '' },
    validationSchema: (values) => {
      const errors: Record<string, string> = {};
      
      if (!values.name) {
        errors.name = 'Name is required';
      }
      
      if (!values.email) {
        errors.email = 'Email is required';
      } else if (!/\S+@\S+\.\S+/.test(values.email)) {
        errors.email = 'Email is invalid';
      }
      
      return errors;
    },
    onSubmit: onSave
  });
  
  return (
    <form onSubmit={form.handleSubmit}>
      <div>
        <input
          type="text"
          value={form.values.name}
          onChange={(e) => form.setValue('name', e.target.value)}
          placeholder="Name"
        />
        {form.errors.name && <span className="error">{form.errors.name}</span>}
      </div>
      
      <div>
        <input
          type="email"
          value={form.values.email}
          onChange={(e) => form.setValue('email', e.target.value)}
          placeholder="Email"
        />
        {form.errors.email && <span className="error">{form.errors.email}</span>}
      </div>
      
      <button type="submit" disabled={form.isSubmitting}>
        {form.isSubmitting ? 'Saving...' : 'Save'}
      </button>
      
      <button type="button" onClick={form.reset}>
        Reset
      </button>
    </form>
  );
}

State Persistence

Local Storage Integration

typescript
// hooks/usePersistentState.ts
import { useState, useEffect } from 'react';

export function usePersistentState<T>(
  key: string, 
  defaultValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState<T>(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : defaultValue;
    } catch {
      return defaultValue;
    }
  });
  
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(state));
    } catch (error) {
      console.warn(`Could not save ${key} to localStorage:`, error);
    }
  }, [key, state]);
  
  return [state, setState];
}

// Usage
function UserPreferences() {
  const [theme, setTheme] = usePersistentState('theme', 'light');
  const [language, setLanguage] = usePersistentState('language', 'en');
  
  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      
      <select value={language} onChange={(e) => setLanguage(e.target.value)}>
        <option value="en">English</option>
        <option value="es">Spanish</option>
      </select>
    </div>
  );
}

Session Storage Integration

typescript
// hooks/useSessionState.ts
import { useState, useEffect } from 'react';

export function useSessionState<T>(
  key: string, 
  defaultValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState<T>(() => {
    try {
      const item = sessionStorage.getItem(key);
      return item ? JSON.parse(item) : defaultValue;
    } catch {
      return defaultValue;
    }
  });
  
  useEffect(() => {
    try {
      sessionStorage.setItem(key, JSON.stringify(state));
    } catch (error) {
      console.warn(`Could not save ${key} to sessionStorage:`, error);
    }
  }, [key, state]);
  
  return [state, setState];
}

// Usage for temporary state that should persist across page refreshes
function FilterState() {
  const [filters, setFilters] = useSessionState('search-filters', {});
  
  // Filters persist during the session but reset when browser closes
  return (
    <div>
      <input 
        value={filters.search || ''}
        onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
      />
    </div>
  );
}

Error State Management

Global Error Handling

typescript
// contexts/ErrorContext.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';

interface ErrorContextType {
  errors: Error[];
  addError: (error: Error) => void;
  removeError: (id: string) => void;
  clearErrors: () => void;
}

const ErrorContext = createContext<ErrorContextType | undefined>(undefined);

export function ErrorProvider({ children }: { children: React.ReactNode }) {
  const [errors, setErrors] = useState<(Error & { id: string })[]>([]);
  
  const addError = useCallback((error: Error) => {
    const errorWithId = { ...error, id: Date.now().toString() };
    setErrors(prev => [...prev, errorWithId]);
    
    // Auto-remove after 5 seconds
    setTimeout(() => {
      setErrors(prev => prev.filter(e => e.id !== errorWithId.id));
    }, 5000);
  }, []);
  
  const removeError = useCallback((id: string) => {
    setErrors(prev => prev.filter(e => e.id !== id));
  }, []);
  
  const clearErrors = useCallback(() => {
    setErrors([]);
  }, []);
  
  return (
    <ErrorContext.Provider value={{ errors, addError, removeError, clearErrors }}>
      {children}
    </ErrorContext.Provider>
  );
}

export function useError() {
  const context = useContext(ErrorContext);
  if (!context) {
    throw new Error('useError must be used within ErrorProvider');
  }
  return context;
}

// Global error boundary
export function GlobalErrorDisplay() {
  const { errors, removeError } = useError();
  
  return (
    <div className="error-container">
      {errors.map(error => (
        <div key={error.id} className="error-toast">
          <span>{error.message}</span>
          <button onClick={() => removeError(error.id)}>×</button>
        </div>
      ))}
    </div>
  );
}

Performance Optimization

State Normalization

typescript
// utils/normalize.ts
export function normalizeById<T extends { id: string | number }>(items: T[]) {
  return items.reduce((acc, item) => {
    acc[item.id] = item;
    return acc;
  }, {} as Record<string | number, T>);
}

export function denormalize<T>(
  normalizedData: Record<string | number, T>,
  ids: (string | number)[]
): T[] {
  return ids.map(id => normalizedData[id]).filter(Boolean);
}

// Usage
function useNormalizedUsers() {
  const { data: users = [] } = useQuery('users', (client) => 
    client.from('users').select('*')
  );
  
  return useMemo(() => ({
    byId: normalizeById(users),
    allIds: users.map(u => u.id)
  }), [users]);
}

Selective Re-renders

typescript
// hooks/useStableCallback.ts
import { useCallback, useRef } from 'react';

export function useStableCallback<T extends (...args: any[]) => any>(callback: T): T {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;
  
  return useCallback(((...args: Parameters<T>) => 
    callbackRef.current(...args)) as T, []);
}

// Prevent unnecessary re-renders
function OptimizedComponent() {
  const [count, setCount] = useState(0);
  
  // This callback won't cause child re-renders
  const handleIncrement = useStableCallback(() => {
    setCount(prev => prev + 1);
  });
  
  return <ChildComponent onIncrement={handleIncrement} />;
}

Best Practices

State Structure

typescript
// Good: Flat state structure
interface AppState {
  user: User | null;
  posts: Post[];
  ui: {
    isLoading: boolean;
    error: string | null;
  };
}

// Avoid: Deeply nested state
interface BadAppState {
  data: {
    user: {
      profile: {
        settings: {
          theme: string;
        };
      };
    };
  };
}

State Updates

typescript
// Good: Immutable updates
setState(prev => ({ ...prev, user: newUser }));

// Good: Using functional updates
setCount(prev => prev + 1);

// Avoid: Direct mutation
state.user = newUser; // Don't do this

State Composition

typescript
// Good: Compose multiple hooks
function useUserDashboard(userId: string) {
  const user = useUser(userId);
  const posts = useUserPosts(userId);
  const analytics = useUserAnalytics(userId);
  
  return {
    user,
    posts,
    analytics,
    isLoading: user.isLoading || posts.isLoading || analytics.isLoading
  };
}

// Use composed state
function Dashboard({ userId }: { userId: string }) {
  const { user, posts, analytics, isLoading } = useUserDashboard(userId);
  
  if (isLoading) return <div>Loading...</div>;
  
  return (
    <div>
      <UserProfile user={user.data} />
      <PostsList posts={posts.data} />
      <Analytics data={analytics.data} />
    </div>
  );
}

Next Steps

Released under the MIT License.