Play+ State Management Helper
Introduction
In the Play+ ecosystem, application state should be predictable, resilient, and easy to reason about. This guide is based on the concept of Resilience by Abstraction, providing a standardized way to handle local, global, and asynchronous state.
A well-architected state management system is crucial for building complex, data-driven applications. Our approach establishes consistent patterns that align with our core design pillars: creating a Distinct and readable architecture, providing an Intuitive API that minimizes boilerplate, and supporting an Inclusive experience by enabling accessible and observable state changes.
Package Info
The Play+ state management helpers and patterns are integrated into the Golden Path starter kit. For existing projects, the core utilities can be installed via a dedicated package.
Description
Package / Path | Description |
---|---|
Golden Path (Recommended) | Pre-installed (/src/stores or /src/app/core ) |
Uplift Path | npm install @playplus/state |
Folder Reference
State management follows our standardized folder structure, separating global state logic from component or feature logic.
File / Directory | Purpose & Guidelines |
---|---|
src/stores/ (React) | The recommended location for global Zustand store definitions (e.g., auth.store.ts ). |
src/app/core/services/ (Angular) | The recommended location for stateful services that use RxJS Subjects. |
config/play.state.config.json | An optional file for overriding default state management behaviors and linting rules. |
Helper - Pillars Alignment
A predictable state management strategy is fundamental to our design philosophy.
Pillar | How This Helper Aligns |
---|---|
Intuitive | Primary Pillar: Abstracts the complexity of libraries like Zustand and RxJS into a simple, predictable pattern. |
Distinct | Enforces a consistent, structured state architecture across all applications, making them easier to navigate and maintain. |
Adaptive | A well-managed state allows the UI to react fluidly and reliably to data changes, adapting to user interactions seamlessly. |
Helper Overview
The Play+ state management solution provides a set of patterns and a smart store factory (createPlayStore
) to abstract the plumbing of state management. Instead of setting up stores from scratch, developers use our pre-configured helper that bakes in best practices for immutability, performance, and debuggability.
It automates and simplifies:
- Store Creation: A single function,
createPlayStore
, sets up a global store with middleware for logging and immutability checks. - Immutability: Automatically uses
immer
behind the scenes to prevent direct state mutations, a common source of bugs. - CI/CD Validation: The toolchain includes scripts to lint for common state management pitfalls, such as storing derived state or creating un-optimized selectors.
- Automated Logging: When integrated with
playlog
, all state mutations can be automatically logged, providing a clear audit trail for debugging.
The goal is for developers to define their state shape and actions, and trust that the system is handling the underlying complexity and enforcement correctly.
Config Options
Optional overrides for state management behavior can be placed in config/play.state.config.json
.
Config Variable | Default Value | Description | Recommended Value |
---|---|---|---|
storeNaming | PascalCase | Enforces a naming convention for store files (e.g., AuthStore.ts ). | PascalCase |
enforceSelectors | true | If true, the linter will warn against selecting the entire state object in components. | true |
allowDerivedState | false | If false, the linter will flag instances where derived data is stored in state. | false |
immutability | strict | The level of immutability enforcement. strict uses immer . | strict |
Helper Methods
Method: createPlayStore
Factory function to create Zustand stores with Play+ middleware.
createPlayStore<T>(
initializer: StateCreator<T>,
options?: { debug: boolean }
): StoreApi<T>
Standards and Enforcement
State Integrity
Rule Area | Description | Implementation Details |
---|---|---|
Derived State | Never store derived values. | Enforced by play:state:check lint script. |
Singleton Stores | Avoid multiple stores for the same domain. | Warns on duplicate store IDs during bootstrap. |
Subscription Boundaries | Detect components that re-render too often. | Profiler plugin or RxJS scheduler tracing. |
Security & Stability
Area | Description | Rule IDs / Notes |
---|---|---|
Immutable State | Prevent direct mutation of state objects. | Enforced by eslint-plugin-immer and immer usage in createPlayStore . |
Retry Budget | Detect repeated failed state transitions. | Async state patterns log failures with counter buckets. |
Framework-Specific Enforcement
React
Concern | Enforcement Details | Rule ID(s) |
---|---|---|
useStore Selector | Prevent stale selector traps and excessive re-renders. | useShallow or other equality functions are recommended. |
Suspense Boundaries | Required for async-heavy global state. | Enforced via a Higher-Order Component (HOC) wrapper. |
Angular
Concern | Rule ID(s) / Notes | |
---|---|---|
Component Inputs | Must use Observables for shared state. | strictChangeDetection rule in tsconfig.json . |
Subject Abuse | Flag manual subscriptions that are not unsubscribed. | ESLint plugin-rxjs with strict mode. |
Usage Examples
React: Creating and Using a Global Auth Store
// src/stores/auth.store.ts
import { createPlayStore } from '@playplus/state';
import { User } from '../types';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
}
// Define the store using the helper
export const useAuthStore = createPlayStore<AuthState>((set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({
user: user,
isAuthenticated: !!user,
}),
}));
// src/components/LoginButton.tsx
import { useAuthStore } from '../stores/auth.store';
function LoginButton() {
// Use a selector to get only the 'setUser' action to prevent unnecessary re-renders
const setUser = useAuthStore((state) => state.setUser);
const handleLogin = () => {
const fakeUser = { id: '1', name: 'Jane Doe' };
setUser(fakeUser);
};
return <button onClick={handleLogin}>Log In</button>;
}
Angular: A Stateful Service with RxJS
// src/app/core/services/auth.store.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, map } from 'rxjs';
import { User } from '../models';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
}
@Injectable({ providedIn: 'root' })
export class AuthStoreService {
private readonly state$ = new BehaviorSubject<AuthState>({
user: null,
isAuthenticated: false,
});
// Expose state as observables
readonly user$ = this.state$.pipe(map(s => s.user));
readonly isAuthenticated$ = this.state$.pipe(map(s => s.isAuthenticated));
// Actions to mutate state
setUser(user: User | null): void {
this.state$.next({
user,
isAuthenticated: !!user,
});
}
}
Additional Info
Why We Created This Helper
State management is one of the most complex parts of modern web development. Without a standardized approach, projects can suffer from:
- Inconsistent patterns across different features.
- Bugs from direct state mutation.
- Poor performance from un-optimized component re-renders.
- Difficulty debugging state changes.
The Play+ state management helper provides an opinionated, production-ready pattern that solves these problems. It abstracts the boilerplate of setting up robust stores and provides automated checks, allowing developers to manage state confidently and consistently.
IDE Setup and Manual Scripts
VS Code Configuration
{
"eslint.validate": ["typescript", "javascript"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
Manual Scripts
Script | Command | Description |
---|---|---|
Check for violations | npm run play:state:check | Runs the state linter across the project. |
Generate a report | npm run play:state:report | Creates a report on unused keys and re-render optimization. |
Troubleshooting Common Issues
-
Problem: Unused state keys are accumulating in the store. Symptoms: Large store files, properties that are never used. Fix: Run
npm run play:state:report
and work with your team to prune unused keys. -
Problem: UI doesn't re-render after a state change. Symptoms: The state seems to change in devtools, but the UI is stale. Fix: This is almost always a direct state mutation. Ensure you are using the spread syntax (
{...state, ...newState}
) or theset
function provided by the store, which usesimmer
to handle immutability for you.
Developer Checklist
- Is my global state defined in the
stores
(React) orcore/services
(Angular) directory? - Am I avoiding storing derived data in my state? (e.g., calculating
fullName
fromfirstName
andlastName
in the component instead of storing it). - In React components, am I using selectors to subscribe to the smallest piece of state necessary?
- Are all state mutations happening through dedicated actions/methods, not by direct manipulation?
- Have I considered if this piece of state truly needs to be global, or can it be local component state?
MISC
Async/Server State
While this guide focuses on client state, Play+ recommends using a dedicated library like TanStack Query (React Query) for managing server cache, which is a different type of state. Our helpers are fully compatible with this approach.
Integration with Other Helpers
This helper is designed to work seamlessly with others. You can call playlog.info()
inside your state actions to trace mutations, or use playerror
to handle failures in asynchronous state logic.
Migration Guide
To migrate a manual Zustand store, simply swap the create
import with createPlayStore
from @playplus/state
. The API is compatible.
// Before
import { create } from 'zustand';
// After
import { createPlayStore } from '@playplus/state';