Best Practices for SoulState
Following these best practices ensures your SoulState applications are performant, maintainable, and scalable. This guide covers store architecture, performance optimization, and React integration patterns.
1. Store Architecture
Modular Domain Stores
Principle: Create focused stores for each business domain instead of a monolithic store.
// ✅ Good: Separate domain stores
// stores/userStore.ts
export const userStore = createStore({
users: [],
loading: false
});
export const userActions = {
fetchUsers: async () => {
userStore.set({ loading: true });
// API call
userStore.set({ users: data, loading: false });
}
};
// stores/productStore.ts
export const productStore = createStore({
products: [],
categories: []
});
// stores/uiStore.ts
export const uiStore = createStore({
theme: 'light',
sidebarOpen: true
};
Why Modular Stores?
Smaller stores mean fewer subscribers to notify, better separation of concerns, and easier testing and maintenance.
Actions Separation
Principle: Keep actions as separate functions rather than embedding them in store state.
// ✅ Correct: Actions as separate exports
export const counterStore = createStore({ count: 0 });
export const counterActions = {
increment: () => counterStore.set(state => ({ count: state.count + 1 })),
decrement: () => counterStore.set(state => ({ count: state.count - 1 })),
reset: () => counterStore.set({ count: 0 })
};
// ❌ Avoid: Actions in store state (not supported)
export const counterStore = createStore({
count: 0,
increment: () => set(...) // This pattern doesn't work in SoulState
};
2. Performance Optimization
Surgical Selectors
Principle: Subscribe to the minimal data needed by each component.
// ✅ Good: Precise selectors
function UserProfile({ userId }) {
// Only re-renders when this specific user's name changes
const userName = useStore(
userStore,
state => state.users.find(u => u.id === userId)?.name
);
return <div>{userName}</div>;
}
// ❌ Avoid: Over-subscribing
function UserProfile({ userId }) {
// Re-renders when ANY user changes
const users = useStore(userStore, state => state.users);
const user = users.find(u => u.id === userId);
return <div>{user?.name}</div>;
}
Shallow Equality for Objects
Principle: Use shallow equality when selecting multiple properties into a new object.
import { useStore } from 'soulstate/react';
import { shallow } from 'soulstate/utils';
// ✅ Good: Use shallow for object selections
const { user, posts } = useStore(
store,
state => ({
user: state.user,
posts: state.posts
}),
shallow // Prevents re-renders when object contents haven't changed
);
// ❌ Avoid: Object creation without shallow
const { user, posts } = useStore(
store,
state => ({ user: state.user, posts: state.posts })
// Re-renders on every state change due to new object creation
);
Multiple Fine-grained Subscriptions
Principle: Use multiple useStore calls for independent data pieces.
// ✅ Good: Independent subscriptions
function Dashboard() {
const user = useStore(userStore, state => state.user);
const notifications = useStore(uiStore, state => state.notifications);
const theme = useStore(uiStore, state => state.theme);
// Each subscription updates independently
return <div className={theme}>...</div>;
}
// ❌ Avoid: Combined subscriptions that cause unnecessary re-renders
function Dashboard() {
const { user, notifications, theme } = useStore(
combinedStore,
state => ({
user: state.user,
notifications: state.notifications,
theme: state.theme
}),
shallow
);
// Re-renders if any of the three properties change
};
3. State Updates
Immutable Updates
Principle: Always create new objects/arrays when updating state.
// ✅ Good: Immutable updates
userActions.updateUser = (userId, updates) => {
userStore.set(state => ({
users: state.users.map(user =>
user.id === userId ? { ...user, ...updates } : user
)
}));
};
// ❌ Avoid: Direct mutation (WON'T WORK)
userActions.updateUser = (userId, updates) => {
userStore.set(state => {
const user = state.users.find(u => u.id === userId);
Object.assign(user, updates); // ❌ Mutation - won't trigger updates
return state;
});
};
Batch Related Updates
Principle: Group related state changes in a single set call.
// ✅ Good: Batch related updates
const formActions = {
submitForm: (formData) => {
// Single update for multiple state changes
formStore.set(state => ({
data: formData,
submitted: true,
loading: false,
lastSubmitted: new Date()
}));
}
};
// ❌ Avoid: Multiple separate updates
const formActions = {
submitForm: (formData) => {
formStore.set({ data: formData }); // 🚫
formStore.set({ submitted: true }); // 🚫
formStore.set({ loading: false }); // 🚫
formStore.set({ lastSubmitted: new Date() }); // 🚫
// Results in 4 separate re-renders
}
};
4. Async Operations
Async Actions Pattern
Principle: Handle async operations with proper loading and error states.
// ✅ Good: Async action pattern
export const apiActions = {
fetchUsers: async () => {
userStore.set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
const users = await response.json();
userStore.set({
users,
loading: false,
lastFetched: new Date()
});
} catch (error) {
userStore.set({
error: error.message,
loading: false
});
}
}
};
Optimistic Updates
Principle: Update UI immediately, then handle server sync.
// ✅ Good: Optimistic updates
export const todoActions = {
addTodo: async (text: string) => {
const tempId = `temp-${Date.now()}`;
const newTodo = { id: tempId, text, completed: false };
// Optimistic update
todoStore.set(state => ({
todos: [...state.todos, newTodo]
}));
try {
// Server sync
const savedTodo = await api.createTodo(text);
// Replace temporary todo with server version
todoStore.set(state => ({
todos: state.todos.map(todo =>
todo.id === tempId ? savedTodo : todo
)
}));
} catch (error) {
// Rollback on error
todoStore.set(state => ({
todos: state.todos.filter(todo => todo.id !== tempId)
}));
// Show error notification
uiActions.addNotification({
type: 'error',
title: 'Failed to create todo'
});
}
}
};
5. TypeScript Best Practices
Proper Typing
Principle: Leverage TypeScript for type safety and better developer experience.
// ✅ Good: Full TypeScript support
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
interface UserState {
users: User[];
currentUser: User | null;
loading: boolean;
}
export const userStore = createStore<UserState>({
users: [],
currentUser: null,
loading: false
});
// TypeScript infers everything correctly
const user = useStore(userStore, state => state.currentUser);
// user is automatically typed as User | null
Derived State Selectors
Principle: Create reusable selectors for computed values.
// ✅ Good: Derived state selectors
export const userSelectors = {
getActiveUsers: (state: UserState) =>
state.users.filter(user => !user.deactivated),
getAdmins: (state: UserState) =>
state.users.filter(user => user.role === 'admin'),
getUserById: (state: UserState, userId: string) =>
state.users.find(user => user.id === userId)
};
// Usage in components
const activeUsers = useStore(userStore, userSelectors.getActiveUsers);
const adminUsers = useStore(userStore, userSelectors.getAdmins);
6. Testing Patterns
Testable Actions
Principle: Structure actions for easy testing.
// ✅ Good: Testable actions
export const counterActions = {
increment: (amount = 1) => {
counterStore.set(state => ({ count: state.count + amount }));
}
};
// Easy to test
test('increment action', () => {
const store = createStore({ count: 0 });
const actions = { increment: () => store.set(state => ({ count: state.count + 1 })) };
actions.increment();
expect(store.get().count).toBe(1);
});
Mock Stores for Testing
Principle: Use mock stores for component testing.
// ✅ Good: Mock stores for testing
// __tests__/UserComponent.test.tsx
const mockUserStore = createStore({
user: { name: 'Test User', email: 'test@example.com' },
loading: false
});
jest.mock('../stores/userStore', () => ({
userStore: mockUserStore,
userActions: { /* mock actions */ }
}));
test('displays user name', () => {
render(<UserProfile />);
expect(screen.getByText('Test User')).toBeInTheDocument();
});
7. Performance Monitoring
Avoid Common Pitfalls
Principle: Be aware of performance anti-patterns.
// ❌ Performance pitfalls to avoid
// 1. Creating new functions in render
function UserList() {
// New function on every render - causes unnecessary re-subscriptions
const users = useStore(store, state => state.users.filter(u => u.active));
return <div>{users.map(user => <User key={user.id} user={user} />)}</div>;
}
// ✅ Solution: Memoize selectors or move logic outside
const selectActiveUsers = (state) => state.users.filter(u => u.active);
function UserList() {
const users = useStore(store, selectActiveUsers);
return <div>{users.map(user => <User key={user.id} user={user} />)}</div>;
}
// 2. Over-subscribing to large arrays
function UserTable() {
// Re-renders when any user in the array changes
const users = useStore(userStore, state => state.users);
return <table>{/* ... */}</table>;
}
// ✅ Solution: Use row-level components
function UserTable() {
const userIds = useStore(userStore, state => state.users.map(u => u.id));
return (
<table>
{userIds.map(id => <UserTableRow key={id} userId={id} />)}
</table>
);
}
Key Takeaways
- ✅ Use modular stores for better separation of concerns
- ✅ Employ surgical selectors to minimize re-renders
- ✅ Leverage
shallowequality for object selections - ✅ Batch related updates in single
setcalls - ✅ Follow immutable update patterns
- ✅ Structure async actions with proper state management
- ✅ Use TypeScript for type safety and better DX