Skip to main content

Getting Started

10 min read

Comparison

Understand when to use Directive versus other popular state management solutions.

Redux, Zustand, XState, and React Query are excellent libraries – each solving distinct problems well. Directive doesn't aim to replace them. It fills a specific niche: constraint-driven business logic where you declare what must be true and let the runtime figure out how. Many apps benefit from pairing Directive with these libraries – for example, React Query for data fetching and caching alongside Directive for the business rules that act on that data.


Feature Comparison

Comparison of state management libraries: Directive, Redux, Zustand, XState, and React Query
DirectiveReduxZustandXStateReact Query
Core
Declarative constraintsPartial
Auto-tracking derivations
Effects systemMiddlewareMiddlewareActions
Multi-module compositionSlicesSlicesActors
Schema validation
Optimistic updatesRTK Query
Resilience
Built-in retry/timeoutRTK QueryPartial
Error boundaries
Batched resolution
Settlement detection
Developer Experience
Snapshots
Time-travel debugging
Plugin architectureMiddlewareMiddleware
Testing utilities
TypeScript inferenceGoodGoodGoodGoodGood
Bundle size (gzip)~28KB*~11KB~1KB~14KB~13KB
Learning curveMediumMediumLowHighLow
Integration
AI agent orchestration
Framework agnostic
SSR support

* Core includes constraint engine, time-travel debugging, and plugin system. Tree-shakeable – typical usage is smaller.

    Redux                            Directive
    ─────                            ─────────
    Action                           Fact Change
       │                                │
       ▼                                ▼
    Reducer                          Constraint
       │                                │
       ▼                                ▼
    Store                            Requirement
       │                                │
       ▼                                ▼
    Selector                         Resolver

Redux

Redux pioneered predictable state management with actions and reducers. Redux Toolkit (RTK) modernizes the experience with less boilerplate, excellent TypeScript inference, and RTK Query for data fetching.

When Redux is Better

  • Large teams with strict conventions
  • Extensive middleware ecosystem needed
  • Lots of existing Redux code

When Directive Adds Value

  • Complex async flows with declarative retry/timeout
  • Automatic dependency tracking instead of manual selectors
  • Constraint-driven logic that reacts to state changes without manual dispatch

Head-to-Head (same machine, same operations)

OperationDirectiveRedux Toolkit
Single read18.3M ops/sec32.7M ops/sec
Single write7.6M ops/sec2.3M ops/sec
1K read/write cycles9.7K ops/sec2.5K ops/sec

Directive reads are slower (proxy overhead for auto-tracking), but writes and cycles are 3-4x faster because Redux dispatches run through the reducer + middleware chain.

vs Redux: Code Comparison

Redux Toolkit:

// RTK slice – much less boilerplate than legacy Redux
const userSlice = createSlice({
  name: 'user',
  initialState: { userId: 0, user: null, loading: false, error: null },
  reducers: {
    setUserId: (state, action) => { state.userId = action.payload; },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.user = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message ?? 'Failed';
      });
  },
});

// Async thunk – you dispatch this when the user changes
const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: number) => api.getUser(userId),
);

// Component must dispatch the thunk at the right time
dispatch(setUserId(123));
dispatch(fetchUser(123));

Directive:

// One module – constraints detect the need automatically
const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      loading: t.boolean(),
      error: t.string().nullable(),
    },
  },

  // Declare the rule – no manual dispatch wiring
  constraints: {
    needsUser: {
      when: (f) => f.userId > 0 && !f.user && !f.loading,
      require: { type: "FETCH_USER" },
    },
  },

  // Retry is declarative – no boilerplate retry wrapper
  resolvers: {
    fetchUser: {
      requirement: "FETCH_USER",
      retry: { attempts: 3, backoff: "exponential" },
      resolve: async (req, context) => {
        context.facts.loading = true;
        try {
          context.facts.user = await api.getUser(context.facts.userId);
        } catch (e) {
          context.facts.error = e instanceof Error ? e.message : 'Failed';
        }
        context.facts.loading = false;
      },
    },
  },
});

// Just set the fact – the constraint handles the rest
system.facts.userId = 123;

Zustand

Zustand is a minimal, hooks-first state manager. Its tiny bundle and simple API make it great for straightforward global state.

When Zustand is Better

  • Simple state with no complex async
  • Smallest possible bundle
  • Quick prototyping

When Directive Adds Value

  • Complex constraints and business rules
  • Automatic retry/timeout
  • Multi-module coordination

We don't include benchmark comparisons here because Zustand and Directive solve different problems. Zustand is a minimal state container – it holds values. Directive is a constraint engine – it evaluates rules and dispatches async work. Comparing their read/write speed would be like comparing a map to a pilot – one shows the terrain, the other navigates it.

vs Zustand: Code Comparison

Zustand:

const useUserStore = create((set, get) => ({
  userId: 0,
  user: null,
  loading: false,

  // Must define the fetch logic inline with guard clauses
  fetchUser: async () => {
    if (get().loading || !get().userId) {
      return;
    }

    set({ loading: true });

    try {
      const user = await api.getUser(get().userId);
      set({ user, loading: false });
    } catch (error) {
      set({ loading: false, error });
    }
  },
}));

// Caller must remember to trigger the fetch manually
useUserStore.getState().fetchUser();

Directive:

// Constraints detect the need automatically – no fetchUser() to call
const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      loading: t.boolean(),
      error: t.string().nullable(),
    },
  },

  constraints: {
    needsUser: {
      when: (f) => f.userId > 0 && !f.user && !f.loading,
      require: { type: "FETCH_USER" },
    },
  },

  resolvers: {
    fetchUser: {
      requirement: "FETCH_USER",
      retry: { attempts: 3, backoff: "exponential" },
      resolve: async (req, context) => {
        context.facts.loading = true;
        try {
          context.facts.user = await api.getUser(context.facts.userId);
        } catch (e) {
          context.facts.error = e instanceof Error ? e.message : 'Failed';
        }
        context.facts.loading = false;
      },
    },
  },
});

// Just set the fact – the constraint handles the rest
system.facts.userId = 123;
await system.settle();

XState

XState is a state machine and statechart library. Its actor model, visual editor, and formal verification support make it ideal for modeling complex UI flows.

When XState is Better

  • Complex UI flows (wizards, multi-step forms)
  • Need visual state machine editor
  • Formal verification requirements

When Directive Adds Value

  • Data-driven constraints (vs explicit state/event graphs)
  • Less ceremony for common patterns
  • AI agent orchestration

Head-to-Head (same machine, same operations)

OperationDirectiveXState
Single write7.6M ops/sec1.2M ops/sec
1K read/write cycles9.7K ops/sec1.2K ops/sec
10K writes1.4K ops/sec119 ops/sec

Both libraries do more work per write than a plain state container – evaluating rules and dispatching actions. Directive's constraint evaluation is faster because it uses incremental tracking (only re-evaluating constraints whose dependencies changed), while XState re-evaluates the full machine on every event.

vs XState: Code Comparison

XState v5:

// Define every possible state and transition explicitly
const userMachine = setup({
  types: {
    context: {} as { userId: number; user: User | null },
    events: {} as
      | { type: 'SET_USER_ID'; userId: number }
      | { type: 'RETRY' },
  },
  guards: {
    hasUserId: (_, params: { userId: number }) => params.userId > 0,
  },
  actors: {
    fetchUser: fromPromise(({ input }: { input: { userId: number } }) =>
      api.getUser(input.userId),
    ),
  },
}).createMachine({
  id: 'user',
  initial: 'idle',
  context: { userId: 0, user: null },
  states: {
    idle: {
      on: {
        SET_USER_ID: {
          target: 'loading',
          guard: { type: 'hasUserId', params: ({ event }) => event },
          actions: assign({ userId: ({ event }) => event.userId }),
        },
      },
    },
    loading: {
      invoke: {
        src: 'fetchUser',
        input: ({ context }) => ({ userId: context.userId }),
        onDone: {
          target: 'success',
          actions: assign({ user: ({ event }) => event.output }),
        },
        onError: { target: 'error' },
      },
    },
    success: {},
    error: {
      on: { RETRY: 'loading' },
    },
  },
});

Directive:

// No explicit state machine – constraints handle transitions
const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
    },
  },

  init: (facts) => {
    facts.userId = 0;
    facts.user = null;
  },

  // One rule replaces idle/loading/success/error states
  constraints: {
    needsUser: {
      when: (f) => f.userId > 0 && !f.user,
      require: { type: "FETCH_USER" },
    },
  },

  // Retry is built in – no manual RETRY event needed
  resolvers: {
    fetchUser: {
      requirement: "FETCH_USER",
      retry: { attempts: 3, backoff: "exponential" },
      resolve: async (req, context) => {
        context.facts.user = await api.getUser(context.facts.userId);
      },
    },
  },
});

React Query / TanStack Query

React Query excels at server state synchronization with built-in caching, background refetching, and optimistic updates. TanStack Query extends this to Vue, Solid, Svelte, and Angular.

When React Query is Better

  • Pure data fetching (CRUD)
  • Background refetching, stale-while-revalidate
  • Pagination, infinite scroll

When Directive Adds Value

  • Complex business logic beyond fetching
  • Multi-step async flows
  • Cross-cutting constraints that React Query wasn't designed for

Pairing Directive with React Query

React Query handles what data to fetch and cache. Directive handles what the system should do about it. They work well together – use React Query for server state, and Directive for the business rules and coordination that act on that data.

vs React Query: Code Comparison

React Query:

// Define a query hook – React Query handles caching and refetching
function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser(userId),
    retry: 3,
    enabled: userId > 0,
  });

  // Each additional dependency needs its own useQuery
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => api.getPosts(userId),
    enabled: !!user, // Manual dependency chain
  });

  // Business logic lives in the component
  if (user && !user.verified) {
    // Must handle this imperatively
  }
}

Directive:

// Constraints express dependencies and business rules declaratively
const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      posts: t.array(t.object<Post>()),
    },
  },

  constraints: {
    needsUser: {
      when: (f) => f.userId > 0 && !f.user,
      require: { type: "FETCH_USER" },
    },
    needsPosts: {
      when: (f) => f.user !== null && !f.posts.length,
      require: { type: "FETCH_POSTS" },
    },
    needsVerification: {
      when: (f) => f.user !== null && !f.user.verified,
      require: { type: "VERIFY_USER" },
    },
  },

  resolvers: {
    fetchUser: {
      requirement: "FETCH_USER",
      retry: { attempts: 3, backoff: "exponential" },
      resolve: async (req, context) => {
        context.facts.user = await api.getUser(context.facts.userId);
      },
    },
    fetchPosts: {
      requirement: "FETCH_POSTS",
      resolve: async (req, context) => {
        context.facts.posts = await api.getPosts(context.facts.userId);
      },
    },
    verifyUser: {
      requirement: "VERIFY_USER",
      resolve: async (req, context) => {
        await api.sendVerification(context.facts.user!.email);
      },
    },
  },
});

Other Libraries

If you just need simple reactive state without rules or async orchestration, Zustand, Jotai, and Preact Signals are excellent choices. They're smaller, faster at raw operations, and simpler to learn. Directive is designed for when state needs to do things – evaluate constraints, dispatch resolvers, track causality – not just hold values.


Performance

Directive reads state at 18.9M ops/sec – well within a 16ms frame budget. The constraint engine is where Directive does work no other library does:

Operationops/secLatency
Full reconcile (5 facts → 3 constraints → 3 resolvers → 5 derivations)18,78053μs
Auth flow (login → token → profile, 3-step constraint chain)35,33428μs
Traffic light (9 event dispatches → 3 constraint transitions)27,00937μs

These numbers have no competitor equivalent – no other library evaluates constraints, diffs requirements, and dispatches resolvers in a single cycle. All the libraries on this page are fast enough for any real application – the right choice depends on your use case, not on benchmarks.

Run benchmarks on your own machine:

pnpm bench

Decision Guide

If you need...Use
Simple global stateZustand
Server state + cachingReact Query
Explicit state machinesXState
Large team + conventionsRedux (RTK)
UI flow state machinesXState
Minimal global storeZustand
Data fetching + cachingReact Query
Declarative business rulesDirective
AI agent orchestrationDirective
Complex async with retryDirective
Multi-module coordinationDirective
Constraint + fetch comboDirective + React Query
State machines + business rulesXState + Directive

Migration Paths

Already using another library? See our integration guides for incremental adoption:


Next Steps

Previous
Core Concepts

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management