Skip to main content

Core API

6 min read

Effects

Effects run side effects without blocking the reconciliation loop.


Basic Effects

Define effects in your module to react to fact changes:

import { createModule, t } from '@directive-run/core';

const analyticsModule = createModule("analytics", {
  schema: {
    facts: {
      page: t.string(),
      userId: t.string().nullable(),
    },
  },

  effects: {
    trackPageView: {
      // Fires whenever page or userId changes
      run: (facts) => {
        analytics.track("page_view", {
          page: facts.page,
          userId: facts.userId,
        });
      },
    },
  },
});

Effect Anatomy

PropertyTypeDescription
run(facts, prev) => void | Promise<void> | (() => void)The side effect to execute. May return a cleanup function.
depsstring[]Optional explicit dependencies for optimization

The run function receives:

  • facts – the current facts (read-only access recommended)
  • prev – a snapshot of all facts from before the last change, or null on first run

Auto-Tracking

By default, effects auto-track which facts are read during run(). On subsequent changes, the effect only re-runs if one of its tracked facts changed:

effects: {
  logUser: {
    // Auto-tracks facts.userId and facts.userName
    // Only re-runs when those specific facts change
    run: (facts) => {
      console.log(`User: ${facts.userId} - ${facts.userName}`);
    },
  },
}

Previous Values

Access the previous facts snapshot to detect transitions:

effects: {
  onStatusChange: {
    run: (facts, prev) => {
      // Detect specific transitions using previous values
      if (prev && prev.status === "pending" && facts.status === "complete") {
        confetti.launch();
        notifyUser("Order complete!");
      }

      if (prev && prev.status === "processing" && facts.status === "failed") {
        errorReporter.capture("Order failed");
      }
    },
  },
}

prev is null on the first run (no previous state exists yet).


Explicit Dependencies

Use deps to declare which facts an effect depends on. This is required for async effects where fact reads after await won't be auto-tracked:

effects: {
  // Sync effects use auto-tracking (no deps needed)
  syncEffect: {
    run: (facts) => {
      console.log(facts.userId);  // Tracked automatically
    },
  },

  // Async effects need explicit deps – reads after await aren't tracked
  asyncEffect: {
    deps: ["userId", "userName"],
    run: async (facts) => {
      await someAsyncOp();
      console.log(facts.userId);  // Safe – dep is declared explicitly
    },
  },
}

Why? Auto-tracking only captures synchronous fact reads. Any reads that happen after an await are invisible to the tracker. Explicit deps guarantee the effect runs when those facts change.


DOM Effects

Effects are perfect for DOM manipulation:

effects: {
  // Update the browser tab title when page changes
  updateTitle: {
    deps: ["pageTitle"],
    run: (facts) => {
      document.title = facts.pageTitle
        ? `${facts.pageTitle} | MyApp`
        : "MyApp";
    },
  },

  // Show a badge favicon when there are unread items
  updateFavicon: {
    deps: ["unreadCount"],
    run: (facts) => {
      const favicon = document.querySelector("link[rel='icon']");
      if (favicon) {
        favicon.href = facts.unreadCount > 0
          ? "/favicon-badge.ico"
          : "/favicon.ico";
      }
    },
  },

  // Scroll to top on route changes
  scrollToTop: {
    run: (facts, prev) => {
      if (prev && facts.currentRoute !== prev.currentRoute) {
        window.scrollTo({ top: 0, behavior: "smooth" });
      }
    },
  },
}

External Service Integration

Connect to external services reactively:

effects: {
  // Sync user profile changes to Firebase in real time
  syncToFirebase: {
    deps: ["userProfile"],
    run: (facts) => {
      if (facts.userProfile) {
        firebase.database()
          .ref(`users/${facts.userProfile.id}`)
          .set(facts.userProfile);
      }
    },
  },

  // Keep Intercom in sync with user data
  sendToIntercom: {
    deps: ["user"],
    run: (facts) => {
      if (facts.user) {
        Intercom("update", {
          user_id: facts.user.id,
          email: facts.user.email,
          name: facts.user.name,
        });
      }
    },
  },
}

Cleanup

Effects can return a cleanup function, similar to React's useEffect. The cleanup runs before the effect re-runs (when deps change) and when the system stops or is destroyed:

effects: {
  // Return a cleanup function to tear down resources
  websocket: {
    deps: ["roomId"],
    run: (facts) => {
      const ws = new WebSocket(`wss://chat.example.com/${facts.roomId}`);
      ws.onmessage = (e) => handleMessage(e.data);

      // Cleanup: close the connection when roomId changes or system stops
      return () => ws.close();
    },
  },

  // Works with intervals, event listeners, etc.
  polling: {
    deps: ["endpoint"],
    run: (facts) => {
      const id = setInterval(() => fetch(facts.endpoint), 5000);

      return () => clearInterval(id);
    },
  },
}

Cleanup functions are called safely – errors in cleanup are caught and logged without breaking the system. If an async effect returns a cleanup function after the system has already been stopped, the cleanup is invoked immediately so resources are never leaked.


Error Isolation

Effects are fire-and-forget – errors are logged but never break the reconciliation loop:

effects: {
  riskyEffect: {
    run: (facts) => {
      // If this throws, the reconciliation loop continues normally
      // Errors are logged and reported via the onError callback
      externalService.send(facts.data);
    },
  },
}

For async effects, handle errors explicitly to avoid unhandled promise rejections:

effects: {
  saveData: {
    deps: ["data"],
    run: async (facts) => {
      try {
        await api.save(facts.data);
      } catch (error) {
        // Handle errors explicitly to avoid unhandled promise rejections
        errorReporter.capture(error);
      }
    },
  },
}

Parallel Execution

Effects run in parallel, not sequentially. They are independent side effects and don't wait for each other:

effects: {
  // All three effects run in parallel – they don't wait for each other
  logEvent: {
    run: (facts) => console.log("Action:", facts.action),
  },
  trackAnalytics: {
    run: (facts) => analytics.track(facts.action),
  },
  notifyUser: {
    run: (facts) => showNotification(facts.action),
  },
}

Best Practices

Don't Mutate Facts

Effects should be read-only side effects, not state mutations:

// Good - only side effects
effects: {
  log: {
    run: (facts) => console.log(facts.status),
  },
}

// Bad - mutating facts
effects: {
  compute: {
    run: (facts) => {
      facts.computed = facts.a + facts.b;  // Don't do this!
    },
  },
}

Use derivations for computed values, events for state mutations.

Use Explicit Deps for Async Effects

// Bad - fact reads after await won't be tracked
effects: {
  bad: {
    run: async (facts) => {
      await delay(100);
      console.log(facts.userId);  // NOT tracked!
    },
  },
}

// Good - explicit deps for async
effects: {
  good: {
    deps: ["userId"],
    run: async (facts) => {
      await delay(100);
      console.log(facts.userId);  // Works because dep is declared
    },
  },
}

Handle Async Errors

effects: {
  save: {
    deps: ["data"],
    run: async (facts) => {
      try {
        await api.save(facts.data);
      } catch (error) {
        errorReporter.capture(error);
      }
    },
  },
}

Runtime Control

Disable or enable effects at runtime:

// Disable an effect – it won't run during reconciliation
system.effects.disable("expensiveAnalytics");

// Re-enable it later
system.effects.enable("expensiveAnalytics");

// Check whether an effect is currently enabled
system.effects.isEnabled("expensiveAnalytics"); // false

This is useful for suppressing noisy effects during tests, pausing analytics, or toggling behavior based on user preferences.


Effects vs Resolvers

AspectEffectsResolvers
PurposeSide effects (logging, DOM, analytics)Fulfill requirements (API calls, data)
TriggerFact changesConstraint activation
Can modify factsNo (read-only recommended)Yes
Cleanup supportYes (return function from run)Yes (via AbortController)
Fire-and-forgetYesNo (tracked, retried, cancelled)
Error handlingIsolated (logged, never breaks engine)Full lifecycle (retry, timeout, abort)
ExecutionParallelParallel (sequential via after)

Next Steps

Previous
Resolvers
Next
Events

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