DOCS CORE_CONCEPTS Understanding Guards

Understanding Guards

Understanding Guards

Guards are conditions that must be met for a transition to occur. They let you add conditional logic to your state machines without complicating the state structure.

Think of guards as gatekeepers - they decide whether a transition can proceed based on the current context and event data. This allows your state machine to make intelligent decisions while keeping your states clean and focused.

Why Guards Matter

Without guards, you’d need separate states for every conditional path, leading to state explosion:

// ❌ Without guards - too many states
const machine = createMachine({
  idle: state(
    transition('submit', 'checkingValid')
  ),
  checkingValid: state(
    transition('isValid', 'checkingPermission')
  ),
  checkingPermission: state(
    transition('hasPermission', 'submitting'),
    transition('noPermission', 'idle')
  )
});

With guards, you can handle conditions inline:

// ✅ With guards - clean and concise
const machine = createMachine({
  idle: state(
    transition('submit', 'submitting',
      guard(isValid),
      guard(hasPermission)
    )
  ),
  submitting: state()
});

Defining Guards

Guards are functions that return true to allow a transition or false to prevent it:

import { createMachine, state, transition, guard } from 'robot3';

const machine = createMachine({
  idle: state(
    transition('submit', 'processing',
      guard((ctx) => ctx.form.isValid)
    )
  ),
  processing: state()
});

Guard Functions

Guard functions receive two arguments:

  • Context: The current machine context
  • Event: The event that triggered the transition
const isValidUser = (ctx, event) => {
  return event.user && event.user.age >= 18;
};

const machine = createMachine({
  waiting: state(
    transition('login', 'authenticated',
      guard(isValidUser)
    )
  ),
  authenticated: state()
}, () => ({
  currentUser: null
}));

// Usage
service.send({
  type: 'login',
  user: { name: 'Alice', age: 25 }
});

Multiple Guards

You can chain multiple guards - all must return true for the transition to proceed:

const isValid = (ctx) => ctx.input.length > 0;
const hasPermission = (ctx) => ctx.user.role === 'admin';
const notRateLimited = (ctx) => ctx.requestCount < 10;

const machine = createMachine({
  idle: state(
    transition('submit', 'processing',
      guard(isValid),
      guard(hasPermission),
      guard(notRateLimited)
    )
  ),
  processing: state()
});

Guards are evaluated in order. If any guard returns false, the transition is blocked and subsequent guards don’t run.

Guards vs State Splits

When should you use guards vs creating separate states? Here’s a guideline:

Use Guards When:

  • The condition is a validation check
  • The condition is temporary or context-based
  • You want to block a transition conditionally
// ✅ Good use of guards
idle: state(
  transition('submit', 'processing',
    guard((ctx) => ctx.email && ctx.password)  // Validation
  )
)

Use Separate States When:

  • The condition represents a distinct mode or phase
  • The state has different UI requirements
  • Different transitions are available in each condition
// ✅ Good use of separate states
loggedOut: state(
  transition('login', 'authenticating')
),
loggedIn: state(
  transition('logout', 'loggingOut')
)

Guard Patterns

Validation Guards

Check if data meets requirements before proceeding:

const hasEmail = (ctx, ev) => ev.email && ev.email.includes('@');
const hasPassword = (ctx, ev) => ev.password && ev.password.length >= 8;
const termsAccepted = (ctx, ev) => ev.acceptedTerms === true;

const machine = createMachine({
  form: state(
    transition('submit', 'submitting',
      guard(hasEmail),
      guard(hasPassword),
      guard(termsAccepted)
    )
  ),
  submitting: state()
});

Permission Guards

Check if user has required permissions:

const isAdmin = (ctx) => ctx.user.role === 'admin';
const isOwner = (ctx, ev) => ctx.user.id === ev.resourceOwnerId;
const hasEditAccess = (ctx) => ctx.user.permissions.includes('edit');

const machine = createMachine({
  viewing: state(
    transition('edit', 'editing',
      guard(isAdmin)  // Only admins can edit
    ),
    transition('delete', 'deleting',
      guard(isOwner)  // Only owner can delete
    )
  ),
  editing: state(),
  deleting: state()
});

Rate Limiting Guards

Prevent too many actions in a time period:

const notRateLimited = (ctx) => {
  const now = Date.now();
  const timeSinceLastRequest = now - ctx.lastRequestTime;
  return timeSinceLastRequest > 1000; // 1 second between requests
};

const machine = createMachine({
  idle: state(
    transition('fetch', 'loading',
      guard(notRateLimited)
    )
  ),
  loading: state()
}, () => ({
  lastRequestTime: 0
}));

Feature Flag Guards

Enable/disable features based on configuration:

const featureEnabled = (ctx) => ctx.features.newUI === true;
const betaUser = (ctx) => ctx.user.betaTester === true;

const machine = createMachine({
  home: state(
    transition('openNewFeature', 'newFeature',
      guard(featureEnabled),
      guard(betaUser)
    )
  ),
  newFeature: state()
});

Conditional Transitions

You can have multiple transitions for the same event with different guards. The first transition whose guards pass will be taken:

const isValid = (ctx) => ctx.data.isValid;
const isInvalid = (ctx) => !ctx.data.isValid;

const machine = createMachine({
  validating: state(
    // First matching transition wins
    transition('done', 'success',
      guard(isValid)
    ),
    transition('done', 'error',
      guard(isInvalid)
    )
  ),
  success: state(),
  error: state()
});

This is how you achieve branching logic in state machines.

Guard Execution Timing

Guards execute before any actions or reducers. This ensures:

  • Guards see the old context (before actions modify it)
  • Actions only run if guards pass
  • State changes only happen if guards pass
const machine = createMachine({
  idle: state(
    transition('submit', 'processing',
      guard((ctx) => {
        console.log('Guard checking:', ctx.value);  // Sees old value
        return ctx.value > 0;
      }),
      reduce((ctx) => {
        console.log('Reducer running:', ctx.value);  // Only runs if guard passes
        return { ...ctx, value: ctx.value + 1 };
      })
    )
  ),
  processing: state()
}, () => ({ value: 5 }));

Pure Guards

Guards should be pure functions - they should:

  • Not modify context or external state
  • Return the same result for the same inputs
  • Not have side effects
// ✅ Good - pure guard
const isValid = (ctx) => ctx.count > 0;

// ❌ Bad - has side effects
const isValidWithSideEffect = (ctx) => {
  console.log('Checking validity');  // Side effect
  ctx.count++;  // Mutates context
  return true;
};

// ❌ Bad - non-deterministic
const isValidRandom = (ctx) => Math.random() > 0.5;  // Random result

Keep guards pure to ensure predictable behavior and easier testing.

Testing Guards

Guards are easy to test since they’re pure functions:

const isEligible = (ctx, ev) => {
  return ev.age >= 18 && ctx.country === 'US';
};

// Test without a full machine
console.assert(
  isEligible({ country: 'US' }, { age: 21 }) === true
);
console.assert(
  isEligible({ country: 'US' }, { age: 16 }) === false
);
console.assert(
  isEligible({ country: 'UK' }, { age: 21 }) === false
);

Common Pitfalls

Avoid Complex Logic in Guards

Keep guards simple and focused:

// ❌ Too complex
const complexGuard = (ctx, ev) => {
  if (ctx.user.role === 'admin') {
    if (ev.action === 'delete') {
      if (ctx.items.length > 0) {
        return ctx.items.every(item => item.status !== 'locked');
      }
    }
  }
  return false;
};

// ✅ Better - break into smaller guards
const isAdmin = (ctx) => ctx.user.role === 'admin';
const isDeleteAction = (ctx, ev) => ev.action === 'delete';
const hasItems = (ctx) => ctx.items.length > 0;
const noLockedItems = (ctx) => ctx.items.every(item => item.status !== 'locked');

transition('submit', 'processing',
  guard(isAdmin),
  guard(isDeleteAction),
  guard(hasItems),
  guard(noLockedItems)
)

Don’t Use Guards for Navigation Logic

Guards should validate conditions, not determine destinations:

// ❌ Bad - using guards for routing
idle: state(
  transition('next', 'stateA', guard(conditionA)),
  transition('next', 'stateB', guard(conditionB)),
  transition('next', 'stateC', guard(conditionC))
)

// ✅ Better - use separate events or immediate transitions
idle: state(
  transition('next', 'deciding')
),
deciding: state(
  immediate('stateA', guard(conditionA)),
  immediate('stateB', guard(conditionB)),
  immediate('stateC')  // Default
)
  • Transitions - How guards control transitions
  • Events - What triggers guard evaluation
  • Actions - What happens after guards pass
  • guard API - Technical reference for the guard function
  • immediate API - Using guards with immediate transitions