State management for complex business logic.
A lightweight, type-safe state management library for TypeScript applications.
- Installation
- Quick Start
- Core Concepts
- Using Signals
- Using Runners
- React Integration
- API Reference
- Troubleshooting
``bash`
npm install @tcn/stateor
yarn add @tcn/stateor
pnpm add @tcn/state
The library provides multiple entry points to optimize your bundle size:
| Entry Point | Description | React Required |
|-------------|-------------|----------------|
| @tcn/state | Everything (backward compatible) | Yes |@tcn/state/core
| | Core utilities only (Signal, Runner, events) | No |@tcn/state/react
| | React hooks only | Yes |
Use this when you need state management in non-React contexts or want to avoid bundling React:
`typescript`
import { Signal, Runner, Event } from '@tcn/state/core';
Use this when you only need the React integration:
`typescript`
import { useSignalValue, useRunnerStatus } from '@tcn/state/react';
For backward compatibility, the main entry point exports everything:
`typescript`
import { Signal, Runner, useSignalValue } from '@tcn/state';
`typescript
// CounterPresenter.ts
class CounterPresenter {
private _countSignal: Signal
get countBroadcast() {
return this._countSignal.broadcast;
}
constructor() {
this._countSignal = new Signal
}
increment() {
this._countSignal.transform(count => count + 1);
}
decrement() {
this._countSignal.transform(count => count - 1);
}
dispose() {
this._countSignal.dispose();
}
}
// Counter.tsx
function Counter({ presenter }: { presenter: CounterPresenter }) {
const count = useSignalValue(presenter.countBroadcast);
return (
Count: {count}
Core Concepts
The library provides two main classes for state management:
1. Signal : Base class for reactive state management
- Manages a single value of type T
- Notifies subscribers when the value changes
- Provides memory-efficient updates through
transform2. Runner : Extends Signal for handling async operations
- Manages async operation state (INITIAL, PENDING, SUCCESS, ERROR)
- Provides progress tracking and error handling
- Supports retry and reset operations
Using Signals
Signals are designed to be encapsulated within classes, providing controlled access to state through readonly interfaces.
$3
`typescript
class TodoListPresenter {
private _todosSignal: Signal;
private _completedTodosSignal: Signal; get todosBroadcast() {
return this._todosSignal.broadcast;
}
get completedCountBroadcast() {
return this._completedTodosSignal.broadcast;
}
constructor() {
this._todosSignal = new Signal([]);
this._completedTodosSignal = new Signal(0);
this._todosSignal.subscribe(todos => {
this._completedTodosSignal.set(
todos.filter(todo => todo.completed).length
);
});
}
dispose() {
this._todosSignal.dispose();
this._completedTodosSignal.dispose();
}
}
`Using Runners
Runners provide a powerful way to manage asynchronous operations with built-in state management.
$3
1. INITIAL: Default state, no operation running
2. PENDING: Operation in progress, progress can be updated
3. SUCCESS: Operation completed successfully
4. ERROR: Operation failed, contains error information
$3
`typescript
class DataServicePresenter {
private _dataRunner: Runner; get dataBroadcast() {
return this._dataRunner.broadcast;
}
constructor() {
this._dataRunner = new Runner(null);
}
async fetchData() {
await this._dataRunner.execute(async () => {
const response = await fetch('/api/data');
return await response.json();
});
}
dispose() {
this._dataRunner.dispose();
}
}
`React Integration
$3
1. Root Presenter Pattern (Recommended)
`typescript
class AppPresenter {
readonly userPresenter: UserPresenter;
constructor() {
this.userPresenter = new UserPresenter();
}
dispose() {
this.userPresenter.dispose();
}
}
`2. Local State Pattern (For isolated components)
`typescript
function MyComponent() {
const [presenter] = useState(() => new MyPresenter());
useEffect(() => {
return () => presenter.dispose();
}, [presenter]);
return ...;
}
`$3
-
useSignalValue: T
- useRunnerStatus: Status
- useRunnerProgress: number
- useRunnerError: Error | nullAPI Reference
$3
#### Methods
-
set(value: T): void
- transform(cb: (val: T) => T): void
- subscribe(callback: (value: T) => void): ISubscription
- dispose(): void$3
#### Methods
-
execute(action: () => Promise: Promise
- dispatch(action: () => Promise: Promise
- retry(): Promise
- reset(): void
- setProgress(progress: number): void
- setFeedback(feedback: string): void
- setError(error: Error | null): void
- dispose(): voidTroubleshooting
1. Memory Management
- Its advised to call
dispose() on signals and runners when they're no longer needed, but not necessary because Signals subscriptions are WeakRefs
- When using the Root Presenter Pattern (injecting presenters through props), DO NOT dispose the presenter in the component
- When using the Local State Pattern (creating presenters with useState), you MUST dispose the presenter in the component's cleanup function2. Performance
- Use
transform for memory-efficient updates
- Avoid creating new arrays/objects when updating state
- Don't create new signals in render methods3. Type Safety
- Always specify generic types for signals and runners
- Use TypeScript's type inference when possible
- Maintain type consistency across your application
Examples
$3
`typescript
import { Signal, Runner } from '@tcn/state/core';class StockPricePresenter {
private _priceSignal: Signal;
private _updateRunner: Runner;
private _ws: WebSocket | null;
private _symbol: string;
get priceBroadcast() {
return this._priceSignal.broadcast;
}
get updateRunnerBroadcast() {
return this._updateRunner.broadcast;
}
constructor(symbol: string) {
this._symbol = symbol;
this._priceSignal = new Signal(0);
this._updateRunner = new Runner();
this._ws = null;
}
async initialize() {
try {
this._ws = new WebSocket(
wss://api.example.com/stock/${this._symbol});
// Handle WebSocket connection
this._ws.onopen = () => {
console.log('WebSocket connected');
}; // Handle WebSocket messages
this._ws.onmessage = (event) => {
const price = JSON.parse(event.data).price;
this._priceSignal.set(price);
};
// Handle WebSocket errors
this._ws.onerror = (error) => {
console.error('WebSocket error:', error);
this._updateRunner.setError(new Error('WebSocket connection failed'));
};
// Handle WebSocket closure
this._ws.onclose = () => {
console.log('WebSocket disconnected');
};
return true;
} catch (error) {
console.error('Failed to initialize WebSocket:', error);
this._updateRunner.setError(new Error('Failed to initialize WebSocket connection'));
return false;
}
}
async refresh() {
await this._updateRunner.dispatch(async () => {
const response = await fetch(
/api/stock/${this._symbol});
const data = await response.json();
this._priceSignal.set(data.price);
});
} dispose() {
this._ws?.close();
this._priceSignal.dispose();
this._updateRunner.dispose();
}
}
// Usage in React component
function StockPriceView({ presenter }: { presenter: StockPricePresenter }) {
const price = useSignalValue(presenter.priceBroadcast);
const status = useRunnerStatus(presenter.updateRunnerBroadcast);
const error = useRunnerError(presenter.updateRunnerBroadcast);
useEffect(() => {
// Initialize WebSocket connection when component mounts
presenter.initialize();
// Cleanup when component unmounts
return () => {
presenter.dispose();
};
}, []);
if (status === 'ERROR') {
return (
Error: {error?.message}
);
} return (
Stock Price: ${price}
);
}
`$3
`typescript
// AppPresenter.ts
class AppPresenter {
// Pattern 1: Readonly property for permanent presenters
// - Used when the child presenter is always needed
// - The child presenter is created once and lives as long as the parent
// - Access is direct and type-safe
readonly toolbarPresenter: ToolbarPresenter; // Pattern 2: Signal for dynamic presenters
// - Used when the child presenter may come and go
// - The child presenter can be created and disposed on demand
// - Access requires checking for null
private _sidebarSignal: Signal;
get sidebarBroadcast() {
return this._sidebarSignal.broadcast;
}
constructor() {
// Pattern 1: Initialize permanent presenters in constructor
this.toolbarPresenter = new ToolbarPresenter();
// Pattern 2: Initialize signal with null for dynamic presenters
this._sidebarSignal = new Signal(null);
}
toggleSidebar() {
if (this._sidebarSignal.get() === null) {
// Pattern 2: Create new presenter when needed
this._sidebarSignal.set(new SidebarPresenter());
} else {
// Pattern 2: Clean up and remove presenter when no longer needed
this._sidebarSignal.get()?.dispose();
this._sidebarSignal.set(null);
}
}
dispose() {
// Pattern 1: Clean up permanent presenters
this.toolbarPresenter.dispose();
// Pattern 2: Clean up dynamic presenters if they exist
this._sidebarSignal.get()?.dispose();
this._sidebarSignal.dispose();
}
}
// App.tsx
function App() {
const [appPresenter] = useState(() => new AppPresenter());
const sidebarPresenter = useSignalValue(appPresenter.sidebarBroadcast);
useEffect(() => {
return () => appPresenter.dispose();
}, [appPresenter]);
return (
{/ Pattern 1: Direct access to permanent presenter /}
{/ Pattern 2: Conditional rendering based on presenter existence /}
{sidebarPresenter && (
)}
);
}
`Presenter Composition Patterns
The library supports two main patterns for composing presenters:
$3
`typescript
class ParentPresenter {
// Child presenter is always available
readonly childPresenter: ChildPresenter;
constructor() {
this.childPresenter = new ChildPresenter();
}
}
`
Use this pattern when:
- The child presenter is always needed
- The child's lifecycle matches the parent's
- You need direct, type-safe access to the child$3
`typescript
class ParentPresenter {
private _childSignal: Signal;
get childBroadcast() {
return this._childSignal.broadcast;
}
constructor() {
this._childSignal = new Signal(null);
}
toggleChild() {
if (this._childSignal.get() === null) {
this._childSignal.set(new ChildPresenter());
} else {
this._childSignal.get()?.dispose();
this._childSignal.set(null);
}
}
}
`
Use this pattern when:
- The child presenter may come and go
- The child's lifecycle is independent of the parent
- You need to conditionally render components based on the child's existence$3
1. Use Permanent Presenters when:
- The child is a core part of the parent's functionality
- The child's state needs to persist as long as the parent exists
- You need direct access to the child's methods and properties
2. Use Dynamic Presenters when:
- The child is optional or can be toggled
- The child's state can be discarded when not needed
- You want to save memory by disposing of unused presenters
- The child's existence affects the UI layout
$3
1. Memory Management:
- Always dispose of presenters when they're no longer needed
- For permanent presenters, dispose them in the parent's dispose method
- For dynamic presenters, dispose them before setting the signal to null
2. Type Safety:
- Use TypeScript's type system to ensure proper access to presenters
- For dynamic presenters, always check for null before accessing
3. Component Integration:
- Use
useSignalValue` to subscribe to dynamic presenter signals