Skip to main content

State Management

The template uses a layered state management approach: React Query (TanStack Query) for server state, React Context for global UI settings, and local component state for ephemeral UI concerns. This page covers each layer, the query client configuration, and patterns used throughout the codebase.

State Categories

CategoryToolExamples
Server stateReact QueryUser data, items, categories, admin stats
Global UI stateReact ContextTheme, layout, pagination type, container width
Local UI stateuseState / useReducerModal open/close, form inputs, dropdown visibility
Persisted preferenceslocalStorage via ContextTheme key, layout key, items per page

React Query Configuration

The query client is created in lib/query-client.ts using a factory function that handles both server and browser environments:

// lib/query-client.ts
import { isServer, QueryClient } from '@tanstack/react-query';

export function createQueryClientInstance(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: true,
retry: (failureCount) => failureCount < 2,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
onError: (error) => {
toast.error(`Mutation Error: ${error.message}`);
},
},
},
});
}

let browserQueryClient: QueryClient | undefined;

export const getQueryClient = () => {
if (isServer) {
return createQueryClientInstance();
} else {
if (!browserQueryClient) browserQueryClient = createQueryClientInstance();
return browserQueryClient;
}
};

Key design decisions:

  • Server isolation: a fresh QueryClient is created per server request to prevent data leaking between users
  • Browser singleton: a single instance is reused across the browser session
  • Conservative refetching: refetchOnWindowFocus and refetchOnMount are disabled by default to minimize network traffic
  • Exponential backoff: retry delays double with each attempt, capped at 30 seconds

Query Key Factory

A dedicated react-query-config.ts file defines query key factories for consistent cache management:

// lib/react-query-config.ts
export const queryKeys = {
billing: {
all: ['billing'] as const,
subscription: () => [...queryKeys.billing.all, 'subscription'] as const,
payments: () => [...queryKeys.billing.all, 'payments'] as const,
user: (userId: string) => [...queryKeys.billing.all, 'user', userId] as const,
},
user: {
all: ['user'] as const,
profile: () => [...queryKeys.user.all, 'profile'] as const,
settings: () => [...queryKeys.user.all, 'settings'] as const,
},
admin: {
all: ['admin'] as const,
users: () => [...queryKeys.admin.all, 'users'] as const,
subscriptions: () => [...queryKeys.admin.all, 'subscriptions'] as const,
},
};

This factory pattern enables targeted cache invalidation. For example, invalidateQueries({ queryKey: queryKeys.billing.all }) clears all billing-related queries at once.

Cache Invalidation Utilities

// lib/react-query-config.ts
export const cacheUtils = {
invalidateBilling: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.billing.all });
},
invalidateSubscription: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.billing.subscription() });
},
resetCache: () => {
queryClient.clear();
},
};

Prefetch Strategies

export const prefetchStrategies = {
billing: () => {
queryClient.prefetchQuery({
queryKey: queryKeys.billing.subscription(),
queryFn: async () => { /* API call */ },
staleTime: 5 * 60 * 1000,
});
},
userProfile: () => {
queryClient.prefetchQuery({
queryKey: queryKeys.user.profile(),
queryFn: async () => { /* API call */ },
staleTime: 10 * 60 * 1000,
});
},
};

These are called proactively when users navigate to pages that will need this data.

Hook Pattern: useCurrentUser

The hooks/use-current-user.ts hook demonstrates the standard data-fetching hook pattern:

// hooks/use-current-user.ts
export const CURRENT_USER_QUERY_KEY = ['auth-session'] as const;

export function useCurrentUser() {
const queryClient = useQueryClient();

const { data: user, isLoading, isError, error, refetch } =
useQuery<User, UseCurrentUserError>({
queryKey: CURRENT_USER_QUERY_KEY,
queryFn: fetchCurrentUser,
staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: (failureCount, error) => {
if (error.status === 401 || error.status === 403) return false;
return failureCount < 2;
},
});

const invalidateUserCache = () => {
queryClient.removeQueries({ queryKey: CURRENT_USER_QUERY_KEY });
};

return { user, isLoading, isError, error, refetch, invalidateUserCache };
}

Pattern highlights:

  • Exported query key: allows other hooks to invalidate or read this cache
  • Smart retry: authentication errors are never retried
  • Cache helpers: invalidateUserCache, prefetchUser, and setUserData are exposed for external use

Optimistic Updates: useFavorites

The hooks/use-favorites.ts hook demonstrates optimistic update patterns:

// hooks/use-favorites.ts (simplified)
const addFavoriteMutation = useMutation({
mutationFn: addFavorite,
onMutate: async (newFavorite) => {
await queryClient.cancelQueries({ queryKey: ['favorites'] });
const previousFavorites =
queryClient.getQueryData<Favorite[]>(['favorites']) ?? [];

// Optimistically add the item
queryClient.setQueryData<Favorite[]>(['favorites'], (old = []) => [
...old,
{ id: `temp-${Date.now()}`, ...newFavorite },
]);

return { previousFavorites };
},
onError: (err, _newFavorite, context) => {
// Rollback on failure
if (context) {
queryClient.setQueryData(['favorites'], context.previousFavorites);
}
toast.error(err.message || 'Failed to add to favorites');
},
onSuccess: (realFavorite) => {
// Replace temp item with server response
queryClient.setQueryData<Favorite[]>(['favorites'], (old = []) =>
old.map((fav) =>
fav.id.startsWith('temp-') && fav.itemSlug === realFavorite.itemSlug
? realFavorite
: fav
)
);
},
});

The pattern follows three steps:

  1. onMutate: cancel in-flight queries, snapshot state, apply optimistic update
  2. onError: rollback to the snapshot
  3. onSuccess: replace the optimistic data with the real server response

Global UI State: LayoutThemeContext

The components/context/LayoutThemeContext.tsx provides a React Context for all global UI preferences:

// components/context/LayoutThemeContext.tsx
interface LayoutThemeContextType {
layoutKey: LayoutKey;
setLayoutKey: (key: LayoutKey) => void;
themeKey: ThemeKey;
setThemeKey: (key: ThemeKey) => void;
currentTheme: ThemeConfig;
paginationType: PaginationType;
setPaginationType: (type: PaginationType) => void;
itemsPerPage: number;
setItemsPerPage: (count: number) => void;
containerWidth: ContainerWidth;
setContainerWidth: (width: ContainerWidth) => void;
// ... more settings
}

Each setting follows the same internal pattern using dedicated manager hooks:

const useThemeManager = () => {
const [themeKey, setThemeKeyState] = useState<ThemeKey>(DEFAULT_THEME);

// Hydrate from localStorage after mount
useEffect(() => {
const saved = safeLocalStorage.getItem('themeKey');
if (saved && isValidThemeKey(saved)) {
setThemeKeyState(saved);
}
}, []);

const setThemeKey = useCallback((key: ThemeKey) => {
setThemeKeyState(key);
safeLocalStorage.setItem('themeKey', key);
applyThemeWithPalettes(key);
}, []);

return { themeKey, setThemeKey, currentTheme };
};

Design principles:

  • Hydration safety: state always initializes with defaults; localStorage is only read in useEffect after mount
  • Validation: every setter validates input before applying
  • Persistence: all preferences are synced to localStorage automatically
  • CSS variable sync: theme changes immediately update CSS custom properties on document.documentElement

Per-Hook Query Keys in Admin Hooks

Each admin CRUD hook defines its own query key namespace:

// hooks/use-admin-categories.ts
const QUERY_KEYS = {
categories: ['admin', 'categories'] as const,
categoriesList: (params) =>
[...QUERY_KEYS.categories, 'list', params] as const,
allCategories: () =>
[...QUERY_KEYS.categories, 'all'] as const,
category: (id: string) =>
[...QUERY_KEYS.categories, 'detail', id] as const,
};

Mutations invalidate at the namespace level to ensure all related queries are refreshed:

onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.categories });
},

File Reference

FilePurpose
lib/query-client.tsQuery client factory (server vs browser)
lib/react-query-config.tsQuery key factories, cache utilities, prefetch strategies
lib/api/constants.tsDefault stale times and query configuration constants
components/context/LayoutThemeContext.tsxGlobal UI settings context with localStorage persistence
hooks/use-current-user.tsExample data-fetching hook with cache management
hooks/use-favorites.tsExample optimistic update pattern
hooks/use-admin-categories.tsExample admin CRUD hook with query key namespacing