Advanced Topics

Design Principles

StateFlow's architecture is built on fundamental principles that address common pitfalls in state management. Understanding these principles will help you build more predictable, maintainable, and debuggable applications.

The Three Pillars

StateFlow is built on three foundational principles that work together to create a robust state management system:

1. Guaranteed State Consistency

Problem: Traditional state management systems allow mutations from anywhere, leading to race conditions and unpredictable behavior.

Solution: StateFlow enforces immutability at both the type level and runtime, making inconsistent states impossible.

// Traditional mutable approach (problematic)
const user = { name: 'Alice', email: 'alice@example.com' };
user.name = 'Bob'; // Silent mutation - who changed this and when?
someAsyncFunction(user); // What if this modifies user?

// StateFlow approach (guaranteed consistency)
const userState = defineState<{ name: string; email: string }>()
  .name("user")
  .variant("active", true)
  .build();

const user = userState.active({ name: 'Alice', email: 'alice@example.com' });
// user.name = 'Bob'; // TypeError: Cannot assign to read only property

// Only way to change state is through signals
const result = dispatch(app, signals.updateName({ name: 'Bob' }));

Benefits:

  • No race conditions: Only one transition can happen at a time
  • Predictable updates: All changes go through defined flows
  • Time-travel debugging: Previous states remain unchanged
  • Safe concurrency: Multiple observers can safely read state

2. Explicit Feedback on Every Operation

Problem: Most state systems silently ignore invalid operations or fail without clear feedback.

Solution: Every signal dispatch returns an explicit Result that categorizes the outcome.

// Traditional approach (silent failures)
store.dispatch(action); // Did this work? Was it ignored? Did it fail?

// StateFlow approach (explicit feedback)
try {
  await dispatch(app, signals.login({ email, password }))
    .expect(ResultKind.OK, ResultKind.Ignored)
    .done();
  console.log('Login successful');
} catch (error) {
  if (error.result?.kind === ResultKind.Rejected) {
    console.log(`Login rejected: ${error.result.message}`);
  } else {
    console.error(`Login failed: ${error}`);
  }
}

Benefits:

  • No silent failures: Every operation provides feedback
  • Clear error messages: Know exactly what went wrong
  • Async operation tracking: Monitor long-running transitions
  • Debugging clarity: Understand system behavior immediately

3. Complete State Visibility

Problem: Complex applications make it difficult to understand current state and track changes.

Solution: StateFlow provides comprehensive introspection tools and human-readable representations.

// Built-in state visibility
const mediaState = defineState<{ 
  position: number; 
  duration: number 
}>()
  .name("media")
  .variant("playing")
  .variant("paused", true)
  .stringRepr(s => `${s.position}/${s.duration}s`)
  .build();

const media = mediaState.playing({ position: 30, duration: 180 });
console.log(media.toString()); // "media.playing(30/180s)"
console.log(stateVar(media)); // "playing"

// Observer patterns for tracking changes
observe(app, [mediaState.playing], (state) => {
  console.log(`Playback position: ${state.position}s`);
});

Benefits:

  • Clear debugging: Human-readable state representations
  • Change tracking: Observers for specific state transitions
  • State introspection: Know exactly which variant is active
  • Logging integration: Built-in support for comprehensive logging

Core Design Decisions

Immutability by Default

StateFlow chooses immutability as the default because it eliminates an entire class of bugs:

// Shallow immutability with Object.freeze()
const state = mediaState.playing({ position: 30, duration: 180 });
Object.isFrozen(state); // true

// Deep freezing for nested objects
const complexState = defineState<{
  user: { id: string; preferences: { theme: string } };
  sessions: Array<{ id: string; active: boolean }>;
}>()
  .name("app")
  .variant("loaded", true)
  .parser(obj => {
    // Custom parser can implement deep freezing
    return deepFreeze(obj);
  })
  .build();

Trade-offs:

  • Pros: No accidental mutations, safe sharing, predictable behavior
  • ⚠️ Cons: Memory overhead for large objects, requires object spreading for updates

Signal-Driven Architecture

All state changes must go through signals, creating a clear audit trail:

// Bad: Direct mutation
app.user.loginAttempts++;

// Good: Signal-driven change
dispatch(app, signals.incrementLoginAttempts());

Benefits:

  • Audit trail: Every change has a named reason
  • Validation: Centralized place for business rules
  • Testing: Easy to simulate any sequence of operations
  • Time travel: Can replay signals to recreate states

Type Safety as First-Class Citizen

StateFlow leverages TypeScript's type system to prevent errors at compile time:

// Type-safe signal parameters
const updateUser = defineSignal<{
  id: string;
  name?: string;
  email?: string;
}>("updateUser");

// Compile error: missing required 'id' property
// dispatch(app, updateUser({ name: 'Alice' }));

// Compile error: invalid property 'age'
// dispatch(app, updateUser({ id: '123', age: 30 }));

// Correct usage
dispatch(app, updateUser({ id: '123', name: 'Alice' }));

Type Safety Features:

  • Signal validation: Parameters checked at compile time
  • State shape enforcement: State properties match definitions
  • Flow handler signatures: Automatic type inference for handlers
  • Result type narrowing: Discriminated unions for result handling

Architectural Philosophy

Prefer Composition Over Inheritance

StateFlow encourages building complex behavior through composition of simple states:

// Instead of complex inheritance hierarchies
class BasePlayer extends EventEmitter {
  // Complex base class with many responsibilities
}

class VideoPlayer extends BasePlayer {
  // Even more complexity
}

// StateFlow prefers composition
const playbackState = defineState<PlaybackProps>()...;
const volumeState = defineState<VolumeProps>()...;
const qualityState = defineState<QualityProps>()...;

// Composed application
interface MediaPlayer {
  playback: Infer<typeof playbackState>;
  volume: Infer<typeof volumeState>;
  quality: Infer<typeof qualityState>;
}

Explicit Over Implicit

StateFlow makes behavior explicit rather than relying on conventions:

// Implicit behavior (hard to understand)
store.subscribe((state) => {
  // When does this run? What triggers it?
  // What if multiple states change?
});

// Explicit behavior (clear intent)
observe(
  app,
  [userState.authenticated, userState.guest], // Specific states
  (state) => updateUI(state), // Clear action
  (prev, curr) => prev.id !== curr.id // Explicit comparison
);

Fail Fast with Clear Messages

StateFlow prefers immediate, clear failures over silent degradation:

// Will throw immediately if flow already defined
defineFlow(userState.guest, { /* handlers */ });
defineFlow(userState.guest, { /* different handlers */ }); // StateFlowError

// Will reject with clear message
defineFlow(userState.guest, {
  login: (state, signal) => {
    if (!isValidEmail(signal.email)) {
      return Result.reject("Invalid email format");
    }
    if (signal.password.length < 8) {
      return Result.reject("Password must be at least 8 characters");
    }
    // ... authentication logic
  }
});

StateFlow vs Alternatives

FeatureReduxMobXZustandStateFlow
ImmutabilityConventionMutationsMixedEnforced
Type SafetySetup requiredDecoratorsManualBuilt-in
AsyncMiddlewarerunInActionManualNative
Error FeedbackSilentBasicBasicExplicit
ComplexityHighMediumLowMedium

When to Choose StateFlow

Best for:

  • Complex applications with multiple interacting states
  • Teams needing structure and maintainability
  • TypeScript-first development
  • Applications requiring comprehensive error handling

Consider alternatives for:

  • Simple applications with minimal state
  • Rapid prototypes
  • Teams unfamiliar with TypeScript

Key Principles in Practice

  1. Design states first - Define data models and variants before writing flows
  2. Use descriptive signals - Signal names should clearly indicate their intent
  3. Handle all result types - Always use .expect() and proper error handling
  4. Leverage type safety - Let TypeScript catch errors at compile time

These principles create applications that are maintainable, debuggable, and predictable over time.

Previous
Architecture guide