This document outlines the state management patterns and best practices used in PerfAgent, built with Zustand.
Never store computed values. Always compute derived state through selectors:
// ❌ Bad - storing computed state
const store = create((set, get) => ({
items: [],
filteredItems: [], // Don't store this
setFilter: (filter) => {
const filtered = get().items.filter(/* ... */);
set({ filteredItems: filtered }); // Unnecessary
}
}));
// ✅ Good - computing via selectors
const store = create((set) => ({
items: [],
filter: ''
}));
const useFilteredItems = () =>
useStore((state) =>
state.items.filter(item => item.includes(state.filter))
);State should reflect UI changes. Use refs for data that doesn't trigger renders:
// ❌ Bad - storing non-UI data in state
const store = create((set) => ({
analyticsTracker: new AnalyticsService(), // Don't trigger renders
websocket: null // Connection state, not UI
}));
// ✅ Good - refs for non-UI data
const analyticsRef = useRef(new AnalyticsService());
const store = create((set) => ({
isConnected: false, // UI state
messages: [] // UI state
}));Always use selectors to prevent unnecessary re-renders:
// ❌ Bad - subscribing to entire store
const Component = () => {
const store = useStore(); // Re-renders on ANY change
return <div>{store.user.name}</div>;
};
// ✅ Good - selective subscriptions
const Component = () => {
const userName = useStore(state => state.user.name);
return <div>{userName}</div>;
};
// ✅ Better - multiple selectors
const Component = () => {
const userName = useStore(state => state.user.name);
const isLoading = useStore(state => state.isLoading);
// Only re-renders when these specific values change
};Organize stores by domain to minimize re-render scope:
// /lib/stores/ui-store.ts
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
isMobile: false,
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen }))
}));
// /lib/stores/chat-store.ts
export const useChatStore = create<ChatStore>((set) => ({
messages: [],
isTyping: false,
addMessage: (message) => set(state => ({
messages: [...state.messages, message]
}))
}));const useStore = create<Store>((set, get) => ({
data: null,
isLoading: false,
error: null,
fetchData: async () => {
set({ isLoading: true, error: null });
try {
const data = await api.getData();
set({ data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
}
}));// Define reusable selectors
export const selectVisibleTodos = (state: TodoStore) =>
state.todos.filter(todo => {
if (state.filter === 'completed') return todo.completed;
if (state.filter === 'active') return !todo.completed;
return true;
});
// Use in components
const visibleTodos = useTodoStore(selectVisibleTodos);import { shallow } from 'zustand/shallow';
// Prevent re-renders when object reference changes but values don't
const { width, height } = useStore(
state => ({ width: state.width, height: state.height }),
shallow
);Always type your stores:
interface StoreState {
count: number;
increment: () => void;
decrement: () => void;
}
const useStore = create<StoreState>((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 }))
}));For nested state updates, use Immer:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useStore = create(
immer<State>((set) => ({
users: {},
updateUser: (id, updates) =>
set((state) => {
state.users[id] = { ...state.users[id], ...updates };
})
}))
);Enable Redux DevTools in development:
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
// your store
}),
{ name: 'MyStore' }
)
);For persistent state across sessions:
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
preferences: {},
setPreference: (key, value) =>
set(state => ({
preferences: { ...state.preferences, [key]: value }
}))
}),
{
name: 'user-preferences',
partialize: (state) => ({ preferences: state.preferences })
}
)
);// /lib/stores/ui-store.ts
interface UIStore {
// State
sidebarOpen: boolean;
activeModal: string | null;
// Actions
toggleSidebar: () => void;
openModal: (modalId: string) => void;
closeModal: () => void;
}// /lib/stores/chat-store.ts
interface ChatStore {
// State
messages: Message[];
isStreaming: boolean;
// Computed via selectors
// Don't store: lastMessage, unreadCount, etc.
// Actions
addMessage: (message: Message) => void;
clearMessages: () => void;
setStreaming: (isStreaming: boolean) => void;
}// /lib/stores/flamegraph-store.ts
interface FlamegraphStore {
// UI State only
selectedNode: string | null;
zoomLevel: number;
// Data should be in refs or props
// Not: traceData, computedMetrics
// Actions
selectNode: (nodeId: string | null) => void;
setZoom: (level: number) => void;
}import { renderHook, act } from '@testing-library/react';
import { useStore } from './store';
describe('Store', () => {
it('should update state correctly', () => {
const { result } = renderHook(() => useStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});When refactoring existing state management:
- Identify UI state vs non-UI data
- Extract derived state into selectors
- Split large stores by domain
- Add TypeScript types for safety
- Implement selectors to prevent re-renders
- Test store updates in isolation
/lib/stores/ui-store.ts- UI state management/lib/stores/chat-store.ts- Chat interface state/lib/stores/flamegraph-store.ts- Visualization state/lib/stores/artifact-store.ts- Artifact management/lib/stores/toast-store.ts- Notification system