Skip to main content

Getting Started

8 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)~3KB~11KB~1KB~14KB~13KB
Learning curveMediumMediumLowHighLow
Integration
AI agent orchestration
Framework agnostic
SSR support

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

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

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

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);
      },
    },
  },
});

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 migration guides:


Next Steps

Previous
Why Directive

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 State Management for TypeScript