Skip to main content

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 / PathDescription
Golden Path (Recommended)Pre-installed (/src/stores or /src/app/core)
Uplift Pathnpm install @playplus/state

Folder Reference

State management follows our standardized folder structure, separating global state logic from component or feature logic.

File / DirectoryPurpose & 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.jsonAn 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.

PillarHow This Helper Aligns
IntuitivePrimary Pillar: Abstracts the complexity of libraries like Zustand and RxJS into a simple, predictable pattern.
DistinctEnforces a consistent, structured state architecture across all applications, making them easier to navigate and maintain.
AdaptiveA 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 VariableDefault ValueDescriptionRecommended Value
storeNamingPascalCaseEnforces a naming convention for store files (e.g., AuthStore.ts).PascalCase
enforceSelectorstrueIf true, the linter will warn against selecting the entire state object in components.true
allowDerivedStatefalseIf false, the linter will flag instances where derived data is stored in state.false
immutabilitystrictThe 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 AreaDescriptionImplementation Details
Derived StateNever store derived values.Enforced by play:state:check lint script.
Singleton StoresAvoid multiple stores for the same domain.Warns on duplicate store IDs during bootstrap.
Subscription BoundariesDetect components that re-render too often.Profiler plugin or RxJS scheduler tracing.

Security & Stability

AreaDescriptionRule IDs / Notes
Immutable StatePrevent direct mutation of state objects.Enforced by eslint-plugin-immer and immer usage in createPlayStore.
Retry BudgetDetect repeated failed state transitions.Async state patterns log failures with counter buckets.

Framework-Specific Enforcement

React

ConcernEnforcement DetailsRule ID(s)
useStore SelectorPrevent stale selector traps and excessive re-renders.useShallow or other equality functions are recommended.
Suspense BoundariesRequired for async-heavy global state.Enforced via a Higher-Order Component (HOC) wrapper.

Angular

ConcernRule ID(s) / Notes
Component InputsMust use Observables for shared state.strictChangeDetection rule in tsconfig.json.
Subject AbuseFlag 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

ScriptCommandDescription
Check for violationsnpm run play:state:checkRuns the state linter across the project.
Generate a reportnpm run play:state:reportCreates 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 the set function provided by the store, which uses immer to handle immutability for you.


Developer Checklist

  • Is my global state defined in the stores (React) or core/services (Angular) directory?
  • Am I avoiding storing derived data in my state? (e.g., calculating fullName from firstName and lastName 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';