Skip to main content

Advanced

5 min read

Error Handling

Directive provides robust error handling for resolvers, constraints, effects, and derivations.


Resolver Error Handling

Handle errors directly in resolver logic with try-catch:

resolvers: {
  fetchUser: {
    requirement: "FETCH_USER",
    resolve: async (req, context) => {
      try {
        // Signal the UI that a request is in flight
        context.facts.loading = true;

        // Fetch and store the user, clearing any previous error
        context.facts.user = await api.getUser(req.userId);
        context.facts.error = null;
      } catch (error) {
        // Store the error message and clear stale user data
        context.facts.error = error.message;
        context.facts.user = null;
      } finally {
        // Always reset loading state, even on failure
        context.facts.loading = false;
      }
    },
  },
}

Retry Policies

Configure automatic retries with backoff:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",

    // Automatically retry on transient failures
    retry: {
      attempts: 3,
      backoff: "exponential",  // "none" | "linear" | "exponential"
      initialDelay: 100,       // ms before first retry
      maxDelay: 5000,          // maximum delay between retries
    },

    // The resolver itself stays simple – retry logic is handled externally
    resolve: async (req, context) => {
      context.facts.data = await api.getData(req.id);
    },
  },
}
OptionTypeDescription
attemptsnumberMaximum retry attempts
backoff"none" | "linear" | "exponential"Backoff strategy
initialDelaynumberDelay before first retry (ms)
maxDelaynumberMaximum delay between retries (ms)
shouldRetry(error, attempt) => booleanPredicate to control whether to retry

Use shouldRetry to skip retries for non-transient errors:

retry: {
  attempts: 5,
  backoff: "exponential",

  // Only retry errors that are likely transient
  shouldRetry: (error, attempt) => {
    // 404 (not found) and 401 (unauthorized) won't resolve with retries
    if (error.message.includes("404") || error.message.includes("401")) {
      return false;
    }
    return true;
  },
},

Timeout

Set a timeout for resolver execution:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    timeout: 5000, // Abort the resolver if it takes longer than 5 seconds

    resolve: async (req, context) => {
      context.facts.data = await slowApi.getData(req.id);
    },
  },
}

When a resolver exceeds its timeout, it is aborted via the context.signal (an AbortSignal). Use it to cancel in-flight requests:

resolve: async (req, context) => {
  // Pass the abort signal to fetch so the request cancels on timeout
  const res = await fetch(`/api/data/${req.id}`, {
    signal: context.signal,
  });

  context.facts.data = await res.json();
},

Error Boundary

Configure system-level error handling with errorBoundary:

const system = createSystem({
  module: myModule,

  // Define a recovery strategy for each error source
  errorBoundary: {
    onConstraintError: "skip",     // Ignore failing constraints
    onResolverError: "retry",      // Retry failed resolvers immediately
    onEffectError: "skip",         // Don't let side-effect errors crash the system
    onDerivationError: "skip",     // Keep the previous derived value on error

    // Global handler – called for every error regardless of source
    onError: (error) => {
      console.error(`[${error.source}] ${error.sourceId}:`, error.message);
      errorReporter.capture(error);
    },
  },
});

Recovery Strategies

StrategyBehavior
"skip"Ignore the error and continue
"retry"Retry the operation immediately
"retry-later"Retry after a delay (configurable)
"disable"Disable the failing constraint/effect/resolver
"throw"Re-throw the error (stops the system)

You can also pass a callback instead of a strategy string:

errorBoundary: {
  // Use a callback for fine-grained control over error recovery
  onResolverError: (error, resolver) => {
    console.error(`Resolver ${resolver} failed:`, error);
    // Implement custom recovery logic here
  },
},

Error Recovery Patterns

Rollback Pattern

resolvers: {
  updateUser: {
    requirement: "UPDATE_USER",
    resolve: async (req, context) => {
      // Save original state before making changes
      const original = context.facts.user;

      // Apply the update optimistically so the UI feels instant
      context.facts.user = { ...original, ...req.updates };

      try {
        // Replace with the server-confirmed version
        context.facts.user = await api.updateUser(req.userId, req.updates);
      } catch (error) {
        // Rollback to the original state on failure
        context.facts.user = original;
        context.facts.error = error.message;
      }
    },
  },
}

Fallback Pattern

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    resolve: async (req, context) => {
      try {
        // Try the primary data source first
        context.facts.data = await api.getData(req.id);
      } catch (error) {
        // Primary failed – fall back to cached data
        try {
          context.facts.data = await cache.getData(req.id);
          context.facts.isStale = true; // Let the UI know this data may be outdated
        } catch (cacheError) {
          // Both sources failed – surface the error to the user
          context.facts.error = "Data unavailable";
        }
      }
    },
  },
}

React Error Boundaries

Combine Directive's error handling with React error boundaries. Pass the system directly – no provider needed:

import { ErrorBoundary } from 'react-error-boundary';

// A fallback UI shown when a React render error occurs
function ErrorFallback({ error, resetErrorBoundary, system }) {
  const handleRetry = () => {
    // Clear the Directive error state, then reset the React boundary
    system.facts.error = null;
    resetErrorBoundary();
  };

  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={handleRetry}>Try again</button>
    </div>
  );
}

// Wrap your app so render errors show the fallback instead of a blank screen
function App({ system }) {
  return (
    <ErrorBoundary FallbackComponent={(props) => <ErrorFallback {...props} system={system} />}>
      <MyComponent system={system} />
    </ErrorBoundary>
  );
}

Error States in Derivations

Use derivations to expose error state to the UI:

derive: {
  // True when an error is present – use to toggle error UI
  hasError: (facts) => facts.error !== null,

  // True when the user can still retry (under the retry limit)
  canRetry: (facts) => facts.error !== null && facts.retryCount < 3,
}

Monitoring with Plugins

Use the plugin system to report errors to monitoring services:

// A plugin that forwards all errors to Sentry for monitoring
const errorMonitor: Plugin = {
  name: 'error-monitor',

  // Fired when a specific resolver fails
  onResolverError: (resolver, req, error) => {
    Sentry.captureException(error, {
      extra: { resolver, requirement: req },
    });
  },

  // Fired for any error across the system
  onError: (error) => {
    Sentry.captureException(error, {
      extra: { source: error.source, sourceId: error.sourceId },
    });
  },
};

// Register the plugin to start capturing errors
const system = createSystem({
  module: myModule,
  plugins: [errorMonitor],
});

Next Steps

Previous
SSR & Hydration

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