Skip to main content

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;
});
};

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 shallow equality for object selections
  • ✅ Batch related updates in single set calls
  • ✅ Follow immutable update patterns
  • ✅ Structure async actions with proper state management
  • ✅ Use TypeScript for type safety and better DX