We're using Valtio for state management due to its optimal handling of complex state relationships, performance requirements, and Web3 integration needs. The implementation follows a proxy-based pattern with separate stores for different concerns:
// Centralized Store Definition
import { proxy, subscribe } from 'valtio';
interface OptionsState {
someOption: boolean;
network: 'testnet' | 'mainnet';
updateFrequency: number;
}
interface PopupState {
isOpen: boolean;
}
// Store Implementation
export const optionsStore = proxy<OptionsState>({
someOption: false,
network: 'testnet',
updateFrequency: 30,
});
export const popupStore = proxy<PopupState>({
isOpen: false,
});
// Actions
export const optionsActions = {
setSomeOption(value: boolean) {
optionsStore.someOption = value;
},
setNetwork(network: 'testnet' | 'mainnet') {
optionsStore.network = network;
},
setUpdateFrequency(seconds: number) {
optionsStore.updateFrequency = seconds;
}
};
export const popupActions = {
setIsOpen(value: boolean) {
popupStore.isOpen = value;
},
toggle() {
popupStore.isOpen = !popupStore.isOpen;
}
};
The stores are organized in a centralized src/stores
directory with the following structure:
src/
├── stores/
│ ├── index.ts # Main store exports
│ ├── leaderboard.ts # Leaderboard-specific state
│ └── settings.ts # Settings and configuration state
-
Centralized State Management:
- All stores are defined in separate files under
src/stores
- Clear TypeScript interfaces for each store
- Action creators for state mutations
- Subscription capabilities for state changes
- All stores are defined in separate files under
-
Usage in React Components:
import { useSnapshot } from 'valtio';
import { optionsStore, optionsActions } from '../stores';
function Options() {
const { someOption, network } = useSnapshot(optionsStore);
return (
<Container>
<Select.Root
value={network}
onValueChange={optionsActions.setNetwork}
>
{/* Component JSX */}
</Select.Root>
</Container>
);
}
- Usage in React Native:
import { useSnapshot } from 'valtio';
import { View, Text, Pressable } from 'react-native';
import { optionsStore, optionsActions } from '../stores';
function MobileOptions() {
const { someOption, network } = useSnapshot(optionsStore);
return (
<View>
<Pressable
onPress={() => optionsActions.setNetwork(
network === 'testnet' ? 'mainnet' : 'testnet'
)}
>
<Text>Current Network: {network}</Text>
</Pressable>
</View>
);
}
- Benefits:
- Direct state mutations for better readability
- Proxy-based updates for optimal performance
- Automatic batching of state updates
- Built-in TypeScript support
- Cross-platform compatibility
- Install Valtio:
bun add valtio
# or
npm install valtio
- Import and use stores:
import { useSnapshot } from 'valtio';
import { optionsStore, optionsActions } from '../stores';
// Use store state
const { network } = useSnapshot(optionsStore);
// Update state
optionsActions.setNetwork('mainnet');
For detailed implementation examples, see:
-
State Organization:
- Keep related state in dedicated store files
- Use clear TypeScript interfaces
- Implement action creators for state mutations
- Use computed values for derived state
-
Component Integration:
- Use
useSnapshot
for reactive state - Subscribe only to needed state properties
- Implement actions as separate objects
- Use TypeScript for better type safety
- Use
-
Extension-specific Considerations:
- Share state between popup and options pages
- Use
chrome.storage
for persistence when needed - Handle initialization and cleanup properly
- Implement error boundaries for state crashes
-
React Native Considerations:
- Use
useSnapshot
for optimal mobile performance - Implement platform-specific actions when needed
- Handle offline state appropriately
- Consider AsyncStorage for persistence
- Use
- Computed Values:
import { derive } from 'valtio/utils';
const derivedStore = derive({
get totalScores() {
return leaderboardStore.scores.reduce((sum, score) => sum + score.value, 0);
}
});
- Subscriptions:
import { subscribe } from 'valtio';
subscribe(optionsStore, () => {
// console.log('Options updated:', optionsStore.network);
});
- State Persistence:
import { proxyWithPersist } from 'valtio/utils';
export const persistedStore = proxyWithPersist({
theme: 'light',
language: 'en',
}, {
name: 'app-settings',
storage: chrome.storage.local,
});
- Async Actions:
export const leaderboardActions = {
async fetchScores() {
try {
leaderboardStore.loading = true;
const scores = await api.getScores();
leaderboardStore.scores = scores;
} finally {
leaderboardStore.loading = false;
}
}
};
Valtio provides a straightforward approach to state persistence using the subscribe
utility. Here's how LeaderPort implements persistent storage:
import { proxy, subscribe } from 'valtio';
// Create the store
export const settingsStore = proxy<SettingsState>({
settings: {
apiKey: "",
refreshInterval: 5,
notifications: true,
},
savedMessage: null,
});
// Add basic persistence
subscribe(settingsStore, () => {
localStorage.setItem('settings-storage', JSON.stringify(settingsStore));
});
// Load persisted state on init
const persistedState = localStorage.getItem('settings-storage');
if (persistedState) {
try {
const parsed = JSON.parse(persistedState);
Object.assign(settingsStore, parsed);
} catch (error) {
console.error('Failed to load persisted settings:', error);
}
}
-
State Creation
- Use
proxy
to create reactive state - Define clear TypeScript interfaces
- Set sensible default values
- Use
-
Persistence Setup
- Subscribe to state changes
- Serialize state to storage
- Handle storage errors gracefully
-
State Initialization
- Load persisted state on startup
- Merge with default values
- Handle parsing errors
-
Platform-Specific Storage
For web applications:
subscribe(store, () => {
localStorage.setItem('store-key', JSON.stringify(store));
});
For browser extensions:
subscribe(store, () => {
chrome.storage.local.set({ 'store-key': store });
});
For React Native:
subscribe(store, async () => {
await AsyncStorage.setItem('store-key', JSON.stringify(store));
});
-
Error Handling
- Always wrap JSON parsing in try/catch
- Provide fallback values
- Log errors appropriately
-
Performance
- Consider debouncing frequent updates
- Only persist necessary state
- Use appropriate storage mechanism
-
Security
- Never store sensitive data
- Sanitize data before storage
- Consider data encryption needs
One of the key reasons for choosing Valtio over Zustand is its more intuitive "mutable state" mental model.
// Zustand requires explicit state updates
const useStore = create((set) => ({
settings: { theme: 'light' },
updateSettings: (newSettings) => set((state) => ({
settings: { ...state.settings, ...newSettings }
}))
}));
// Valtio allows direct mutations
const store = proxy({
settings: { theme: 'light' }
});
// Just modify the state directly
store.settings.theme = 'dark';
-
Reduced Boilerplate
- No need for spreads (
...state
) - No explicit setter functions required
- Fewer lines of code for state updates
- No need for spreads (
-
More Intuitive Updates
- Direct property access feels natural
- Matches how we think about state changes
- Similar to class property updates
-
Easier Debugging
- State changes are more traceable
- Stack traces point directly to mutations
- Simpler to understand what changed
-
Reduced Cognitive Load
- No need to think about immutability
- Fewer patterns to remember
- More straightforward mental mapping
-
Better TypeScript Integration
- Type inference works naturally
- No complex generic types needed
- IDE autocomplete works as expected
With Zustand:
const useStore = create((set) => ({
nested: { deep: { value: 0 } },
updateValue: (newValue) => set((state) => ({
nested: {
...state.nested,
deep: {
...state.nested.deep,
value: newValue
}
}
}))
}));
With Valtio:
const store = proxy({
nested: { deep: { value: 0 } }
});
// Simply update the value
store.nested.deep.value = 1;
This mutable state approach is particularly beneficial for LeaderPort because:
- We handle complex nested state (leaderboards, achievements, settings)
- We need frequent state updates (real-time data)
- We want to reduce cognitive overhead for contributors
- We prioritize code maintainability and readability
LeaderPort's React applications will rely on Valtio as a personal preference, primarily due to its similarity to Vue 3's Reactive API mental model.
I rely upon this reactive approach in all of my Vue apps, so let's compare both approaches:
import { reactive, computed } from 'vue';
// State definition
const state = reactive({
metadata: {},
loadingMeta: false,
metaLoaded: false,
error: ''
});
// Computed values (similar to getters)
const getters = reactive({
isMetaLoading: computed(() => state.loadingMeta),
isMetaLoaded: computed(() => state.metaLoaded)
});
// Actions
const actions = {
async fetchMeta() {
state.loadingMeta = true;
try {
state.metadata = await api.getMeta();
state.metaLoaded = true;
} catch (error) {
state.error = error.message;
} finally {
state.loadingMeta = false;
}
}
};
import { proxy, subscribe } from 'valtio';
import { derive } from 'valtio/utils';
// State definition
const state = proxy({
metadata: {},
loadingMeta: false,
metaLoaded: false,
error: ''
});
// Computed values (similar to Vue's computed)
const derived = derive({
isMetaLoading: get => get(state).loadingMeta,
isMetaLoaded: get => get(state).metaLoaded
});
// Actions
const actions = {
async fetchMeta() {
state.loadingMeta = true;
try {
state.metadata = await api.getMeta();
state.metaLoaded = true;
} catch (error) {
state.error = error.message;
} finally {
state.loadingMeta = false;
}
}
};
So for me, this makes sense in terms of keeping that same mental model.
-
Direct Mutations
- Both allow direct state mutations
- No need for immutable updates
- Natural state management flow
-
Reactive Updates
- Both automatically track dependencies
- Components re-render only when needed
- Efficient change detection
-
Computed Values
- Both support derived state
- Automatic dependency tracking
- Cached computation results
-
Action Pattern
- Both encourage organizing mutations in actions
- Clear separation of concerns
- Predictable state updates
The similarity between Valtio and Vue 3's Reactive API means:
- Easier mental context switching between frameworks
- Familiar patterns for Vue developers working with React
- Consistent state management approaches across projects
- Reduced learning curve for team members
- More intuitive debugging and state tracking
This alignment with Vue 3's mental model makes Valtio an excellent choice for teams that:
- Work with both React and Vue
- Are transitioning from Vue to React
- Want consistent patterns across frameworks
- Value simplicity and directness in state management
While Valtio's mutable state approach offers significant benefits, it's important to acknowledge that this pattern can be controversial among React developers, particularly those who strongly advocate for immutability. Here's why this matters:
-
Unintended Side Effects
- Direct mutations can affect state in unexpected places
- Changes can propagate through the app unpredictably
- Debugging becomes harder without proper tracking
// Dangerous: Mutation within a component function BadComponent() { // This directly mutates shared state outside of actions store.someValue.nested.property = 'new value'; return <div>...</div>; }
-
Race Conditions
- Async operations can lead to state inconsistencies
- Multiple mutations might conflict
// Potential race condition async function riskyOperation() { store.loading = true; const result = await api.getData(); store.data = result; // What if another operation changed store.loading? store.loading = false; }
-
Centralize Mutations
// Good: Mutations contained in actions const actions = { updateData(newData: Data) { store.data = newData; } };
-
Test Coverage Requirements
describe('store mutations', () => { it('should handle concurrent updates safely', async () => { const promise1 = actions.updateAsync('value1'); const promise2 = actions.updateAsync('value2'); await Promise.all([promise1, promise2]); // Verify state consistency }); });
-
Implement State Guards
const actions = { updateValue(value: string) { if (store.isLocked) return; // Guard against unwanted mutations if (typeof value !== 'string') { throw new Error('Invalid value type'); } store.value = value; } };
-
State Transition Tests
- Test all possible state mutations
- Verify state consistency after updates
- Check for side effects
-
Concurrent Operation Tests
- Test multiple simultaneous updates
- Verify race condition handling
- Check async operation safety
-
Integration Tests
- Test store interactions with components
- Verify subscription behaviors
- Check derived state calculations
Remember: With mutable state patterns, the responsibility for maintaining state integrity shifts from the framework to the development team. This requires:
- Strict adherence to mutation patterns
- Comprehensive test coverage
- Clear documentation of state changes
- Regular code reviews focusing on state mutations
- Monitoring tools for state changes in production
By acknowledging these challenges and implementing proper safeguards, teams can successfully leverage Valtio's mutable state model while maintaining application reliability.
LeaderPort implements a centralized store pattern using a single index.ts
file that combines related state concerns. This approach provides several benefits:
// src/stores/index.ts
import { proxy } from "valtio";
import { derive } from "derive-valtio";
import { subscribeKey } from "valtio/utils";
// Type definitions for each domain
interface WalletState {
address: string | null;
connected: boolean;
}
interface OptionsState {
someOption: boolean;
network: "testnet" | "mainnet";
updateFrequency: number;
}
// Combined store type
type StoreType = {
wallet: WalletState;
options: OptionsState;
popup: PopupState;
};
// Single store instance with initial state
export const store = proxy<StoreType>({
wallet: initialState.wallet,
options: initialState.options,
popup: initialState.popup,
});
// Derived state using derive-valtio
export const derived = derive({
isTestnet: get => get(store.options).network === "testnet",
hasWallet: get => get(store.wallet).connected && !!get(store.wallet).address,
canInteract: get => get(store.wallet).connected && !get(store.popup).isOpen,
});
// Domain-specific actions
export const actions = {
wallet: {
connect(address: string) {
store.wallet.address = address;
store.wallet.connected = true;
},
// ... other wallet actions
},
options: {
setNetwork(network: "testnet" | "mainnet") {
store.options.network = network;
},
// ... other options actions
},
// ... other domains
};
-
Single Source of Truth
- All state definitions in one location
- Clear type relationships between domains
- Easier to track state dependencies
-
Simplified Imports
- Components import from one location
import { store, actions, derived } from '../stores';
- Reduced import complexity
- Better tree-shaking potential
-
Type Safety
- Centralized type definitions
- Better TypeScript inference
- Easier to maintain type consistency
-
State Relationships
- Clear visibility of cross-domain state
- Easier to manage derived state
- Simplified state dependencies
-
Action Organization
- Domain-specific action grouping
- Clear mutation boundaries
- Easier to track state changes
import { useSnapshot } from 'valtio';
import { store, actions, derived } from '../stores';
function NetworkSelector() {
const { network } = useSnapshot(store.options);
const { isTestnet } = useSnapshot(derived);
return (
<select
value={network}
onChange={(e) => actions.options.setNetwork(e.target.value as "testnet" | "mainnet")}
>
<option value="testnet">Testnet</option>
<option value="mainnet">Mainnet</option>
</select>
);
}
The centralized pattern makes it easy to implement app-wide features:
// Global reset
actions.reset = () => {
Object.assign(store, initialState);
};
// Selective persistence
if (typeof window !== "undefined") {
// Load persisted state
const saved = localStorage.getItem("app-state");
if (saved) {
try {
const parsed = JSON.parse(saved);
Object.assign(store.options, parsed.options);
} catch (e) {
console.error("Failed to load persisted state:", e);
}
}
// Subscribe to changes
subscribeKey(store.options, "network", () => {
localStorage.setItem("app-options", JSON.stringify(store.options));
});
}
This centralized approach provides a scalable and maintainable state management solution while keeping the benefits of Valtio's mutable state model.
While this centralized pattern may appear to deviate from Valtio's "keep it simple" philosophy, it becomes necessary in complex applications for several reasons:
-
State Interdependencies
- In blockchain applications like LeaderPort, wallet state affects multiple features
- Network selection influences API calls across the entire application
- Popup states need to coordinate with other UI elements
// Example of state interdependency in derived state export const derived = derive({ canInteract: get => get(store.wallet).connected && !get(store.popup).isOpen, canSubmitTransaction: get => get(store.wallet).connected && get(store.options).network === "mainnet" && !get(store.popup).isOpen });
-
Type Safety at Scale
- Centralized type definitions prevent inconsistencies
- Easier to maintain type relationships between domains
- Better IDE support and error catching
// Types are defined once and used consistently type StoreType = { wallet: WalletState; options: OptionsState; popup: PopupState; };
-
Predictable State Updates
- Actions are organized by domain but share a common pattern
- State changes are traceable through a single source
- Easier to implement logging and debugging
// Consistent action patterns across domains export const actions = { wallet: { /* wallet actions */ }, options: { /* options actions */ }, popup: { /* popup actions */ } };
-
Performance Optimization
- Centralized subscriptions are easier to debug
- Derived state can be optimized in one place
- Selective component updates are more predictable
// Centralized subscription management subscribeKey(store.options, "network", (value) => { localStorage.setItem("app-options", JSON.stringify(store.options)); analytics.track("network_changed", { network: value }); });
-
Development Team Coordination
- Clear conventions for state management
- Consistent patterns across features
- Easier onboarding for new team members
While Valtio excels at simplicity in smaller applications, this centralized pattern helps manage complexity as your application grows, without sacrificing the core benefits of Valtio's reactive approach.