Skip to content

Actions

Actions are functions that modify store state. They provide a clear, testable interface for state changes and are the only recommended way to update state in Storion.

What Are Actions?

What's this for? Understanding why actions exist helps you write better, more maintainable code.

Actions serve several purposes:

  • Encapsulation — Logic for modifying state lives with the state itself
  • Reusability — Call the same action from any component or other store
  • Testability — Test state changes without rendering components
  • Traceability — Track what changed and why via onDispatch
  • Type safety — Parameters and return types are automatically inferred
ts
// Without actions: scattered, hard to track, duplicate logic
component1: () => { state.count++; }
component2: () => { state.count++; analytics.track('increment'); }
component3: () => { state.count++; }  // Forgot analytics!

// With actions: centralized, consistent, trackable
const counterStore = store({
  setup({ state }) {
    return {
      increment: () => {
        state.count++;
        analytics.track('increment');  // Always happens
      },
    };
  },
});

Defining Actions

What's this for? Learn the basic syntax for creating actions.

Actions are returned from the setup() function:

ts
import { store } from 'storion/react';

const counterStore = store({
  name: 'counter',
  state: { count: 0 },
  setup({ state }) {
    // setup() runs ONCE when the store is created
    // Return an object containing your actions
    return {
      // Action: no parameters
      increment: () => {
        state.count++;
      },
      
      // Action: no parameters
      decrement: () => {
        state.count--;
      },
      
      // Action: no parameters
      reset: () => {
        state.count = 0;
      },
      
      // Action: with parameter
      setCount: (value: number) => {
        state.count = value;
      },
      
      // Action: with multiple parameters
      adjustBy: (amount: number, multiply: boolean) => {
        state.count = multiply 
          ? state.count * amount 
          : state.count + amount;
      },
    };
  },
});

Key points:

  • Actions are plain functions that modify state
  • Parameters are fully typed via TypeScript inference
  • Actions can access the reactive state proxy directly
  • Multiple state changes in one action are batched

Calling Actions

What's this for? Learn the different ways to invoke actions.

From React Components

Use useStore to access actions:

tsx
function Counter() {
  // Destructure what you need from the selector
  const { count, increment, decrement, reset } = useStore(({ get }) => {
    const [state, actions] = get(counterStore);
    
    return {
      count: state.count,
      // Actions are stable references - safe for deps arrays
      increment: actions.increment,
      decrement: actions.decrement,
      reset: actions.reset,
    };
  });

  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Tip: Action references are stable across re-renders. You can safely pass them to memoized components or use them in useCallback deps.

From Other Stores

Use get() in setup to access another store's actions:

ts
const analyticsStore = store({
  name: 'analytics',
  state: { events: [] as string[] },
  setup({ state, get }) {
    // Get counterStore at setup time
    const [counterState, counterActions] = get(counterStore);

    return {
      // Compose actions from multiple stores
      trackAndIncrement: () => {
        // Log the event
        state.events = [...state.events, 'increment'];
        // Call the other store's action
        counterActions.increment();
      },
      
      // Access other store's state (always current)
      getCountWithEvents: () => ({
        count: counterState.count,
        eventCount: state.events.length,
      }),
    };
  },
});

From Outside React

Use container.get() to access the store instance directly:

ts
import { container } from 'storion';

// Get the store instance
const instance = app.get(counterStore);

// Call actions directly
instance.actions.increment();
instance.actions.setCount(100);

// Read state
console.log(instance.state.count);

This is useful for:

  • Node.js scripts
  • Background workers
  • Integration tests
  • Initial data loading

Action Patterns

What's this for? Learn common patterns for different types of state updates.

Simple Setters

For single property updates, use direct assignment:

ts
setup({ state }) {
  return {
    setName: (name: string) => {
      state.name = name;  // Direct mutation at first level
    },
    setEmail: (email: string) => {
      state.email = email;
    },
    setAge: (age: number) => {
      state.age = age;
    },
  };
}

Computed Updates

Actions can compute new values from current state:

ts
setup({ state }) {
  return {
    // Double the current count
    double: () => {
      state.count = state.count * 2;
    },
    
    // Add percentage to total
    addPercentage: (percent: number) => {
      state.total = state.total * (1 + percent / 100);
    },
    
    // Toggle boolean
    toggleActive: () => {
      state.isActive = !state.isActive;
    },
    
    // Cycle through values
    nextStatus: () => {
      const statuses = ['pending', 'active', 'done'] as const;
      const currentIndex = statuses.indexOf(state.status);
      state.status = statuses[(currentIndex + 1) % statuses.length];
    },
  };
}

Batch Updates

Multiple state changes in one action trigger a single notification to subscribers:

ts
setup({ state }) {
  return {
    // All changes are batched - subscribers notified once
    reset: () => {
      state.name = '';
      state.email = '';
      state.age = 0;
      state.isVerified = false;
    },
    
    // Load user data in one batch
    loadUser: (user: User) => {
      state.name = user.name;
      state.email = user.email;
      state.age = user.age;
      state.isVerified = user.isVerified;
    },
  };
}

Nested Updates with update()

For nested objects and arrays, use update() with an Immer-style producer:

ts
setup({ state, update }) {
  return {
    // Add item to array
    addTodo: (text: string) => {
      update((draft) => {
        draft.todos.push({
          id: crypto.randomUUID(),
          text,
          done: false,
        });
      });
    },
    
    // Find and modify item
    toggleTodo: (id: string) => {
      update((draft) => {
        const todo = draft.todos.find((t) => t.id === id);
        if (todo) {
          todo.done = !todo.done;
        }
      });
    },
    
    // Remove from array
    removeTodo: (id: string) => {
      update((draft) => {
        draft.todos = draft.todos.filter((t) => t.id !== id);
      });
    },
    
    // Update nested object
    updateProfile: (changes: Partial<Profile>) => {
      update((draft) => {
        Object.assign(draft.profile, changes);
      });
    },
  };
}

update.action() Shorthand

Create actions directly from update functions for cleaner code:

ts
setup({ update }) {
  return {
    // No arguments - just the producer
    increment: update.action((draft) => {
      draft.count++;
    }),
    
    // With arguments - passed after draft
    setCount: update.action((draft, value: number) => {
      draft.count = value;
    }),
    
    // Multiple arguments
    addItem: update.action((draft, name: string, price: number) => {
      draft.items.push({ name, price });
    }),
  };
}

Async Actions

What's this for? Handle data fetching and other asynchronous operations.

Actions can be async for API calls and other async operations:

ts
setup({ state }) {
  return {
    fetchUser: async (id: string) => {
      // Set loading state
      state.loading = true;
      state.error = null;

      try {
        // Make API call
        const response = await fetch(`/api/users/${id}`);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        
        const user = await response.json();
        
        // Update state with result
        state.user = user;
      } catch (err) {
        // Handle error
        state.error = err instanceof Error ? err.message : 'Failed';
      } finally {
        // Always clear loading
        state.loading = false;
      }
    },
  };
}

Use async.action() for Production

For production apps, use async.action() instead of plain async functions. It provides:

  • Automatic loading/error state — No manual loading = true/false
  • Request cancellation — Cancel pending requests on new calls
  • Race condition handling — Only apply results from latest request
  • Retry support — Built-in retry with backoff

See Async State for details.


Action Composition

What's this for? Build complex actions from simpler ones.

Calling Actions from Actions

Actions can call other actions in the same store:

ts
setup({ state }) {
  // Define base actions
  const setName = (name: string) => {
    state.name = name;
  };
  
  const setEmail = (email: string) => {
    state.email = email;
  };
  
  const clearProfile = () => {
    setName('');
    setEmail('');
  };

  return {
    setName,
    setEmail,
    clearProfile,
    
    // Higher-level action using others
    updateProfile: (profile: { name?: string; email?: string }) => {
      if (profile.name) setName(profile.name);
      if (profile.email) setEmail(profile.email);
    },
  };
}

Shared Logic with Helper Functions

Extract shared logic into helper functions:

ts
setup({ state, update }) {
  // Helper function (not an action)
  const findItemById = (id: string) => {
    return state.items.find((item) => item.id === id);
  };
  
  // Helper for validation
  const validateItem = (item: Item) => {
    if (!item.name) throw new Error('Item name required');
    if (item.price < 0) throw new Error('Price must be positive');
  };

  return {
    updateItem: (id: string, changes: Partial<Item>) => {
      const item = findItemById(id);
      if (!item) throw new Error('Item not found');
      
      const updated = { ...item, ...changes };
      validateItem(updated);
      
      update((draft) => {
        const index = draft.items.findIndex((i) => i.id === id);
        draft.items[index] = updated;
      });
    },
  };
}

Action Metadata

What's this for? Track and debug action calls.

Tracking Dispatches

Use onDispatch to log or track all action calls:

ts
const userStore = store({
  name: 'user',
  state: { name: '' },
  setup({ state }) {
    return {
      setName: (name: string) => {
        state.name = name;
      },
    };
  },
  
  // Called after every action completes
  onDispatch: (event) => {
    console.log(`[${event.name}] called with:`, event.args);
    console.log(`Duration: ${event.duration}ms`);
    console.log(`State after:`, event.state);
    
    // Send to analytics
    analytics.track('store_action', {
      store: 'user',
      action: event.name,
      duration: event.duration,
    });
  },
});

Last Action Result

Access the result of the last action call with action.last():

tsx
function SaveButton() {
  const { save, lastSave } = useStore(({ get }) => {
    const [, actions] = get(formStore);
    
    return {
      save: actions.save,
      // Get status of last save call
      lastSave: actions.save.last(),
    };
  });

  // lastSave: { status, result, error, args } | undefined
  
  return (
    <button 
      onClick={save} 
      disabled={lastSave?.status === 'pending'}
    >
      {lastSave?.status === 'pending' ? 'Saving...' : 'Save'}
    </button>
  );
}

Common Mistakes

What's this for? Avoid frequent pitfalls when working with actions.

1. Modifying Nested State Directly

ts
// ❌ Wrong - nested mutation ignored
state.profile.name = 'John';
state.items.push(newItem);

// ✅ Correct - use update() for nested changes
update((draft) => {
  draft.profile.name = 'John';
  draft.items.push(newItem);
});

2. Calling get() Inside Actions

ts
setup({ get }) {
  // ✅ Correct - get() at setup time
  const [otherState, otherActions] = get(otherStore);
  
  return {
    doSomething: () => {
      // ❌ Wrong - get() throws inside actions
      // const [other] = get(anotherStore);
      
      // ✅ Correct - use captured reference
      otherActions.doThing();
    },
  };
}

3. Forgetting to Await Async Actions

ts
// ❌ Wrong - not awaiting async action
const handleSubmit = () => {
  actions.saveUser(userData);
  navigate('/success');  // Navigates before save completes!
};

// ✅ Correct - await the async action
const handleSubmit = async () => {
  await actions.saveUser(userData);
  navigate('/success');  // Navigates after save completes
};

4. Creating Actions Outside setup()

ts
// ❌ Wrong - action defined outside setup
const badAction = () => {
  state.count++;  // 'state' is not accessible here
};

// ✅ Correct - action defined inside setup
setup({ state }) {
  return {
    goodAction: () => {
      state.count++;  // 'state' is accessible
    },
  };
}

5. Using Anonymous Functions in trigger()

ts
// ❌ Wrong - anonymous function creates new reference each render
trigger(() => actions.fetch(id), [id]);

// ✅ Correct - pass stable function reference
trigger(actions.fetch, [id], id);

Recipes: Common Action Patterns

What's this for? Copy-paste solutions for common scenarios.

Recipe: Optimistic Updates

Update UI immediately, rollback on error:

ts
setup({ state, update }) {
  return {
    toggleFavorite: async (itemId: string) => {
      // Save original state
      const original = state.items.find((i) => i.id === itemId);
      if (!original) return;
      
      // Optimistically update
      update((draft) => {
        const item = draft.items.find((i) => i.id === itemId);
        if (item) item.isFavorite = !item.isFavorite;
      });
      
      try {
        // Make API call
        await api.toggleFavorite(itemId);
      } catch (error) {
        // Rollback on error
        update((draft) => {
          const item = draft.items.find((i) => i.id === itemId);
          if (item) item.isFavorite = original.isFavorite;
        });
        throw error;
      }
    },
  };
}

Recipe: Debounced Action

Prevent rapid-fire calls:

ts
setup({ state }) {
  let timeoutId: ReturnType<typeof setTimeout>;
  
  return {
    // Debounced search
    search: (query: string) => {
      clearTimeout(timeoutId);
      
      timeoutId = setTimeout(async () => {
        state.searching = true;
        try {
          state.results = await api.search(query);
        } finally {
          state.searching = false;
        }
      }, 300);  // Wait 300ms before searching
    },
  };
}

Recipe: Action with Confirmation

ts
setup({ state }) {
  return {
    deleteItem: async (id: string, confirmed = false) => {
      // If not confirmed, set pending deletion
      if (!confirmed) {
        state.pendingDeleteId = id;
        return;
      }
      
      // Clear pending and delete
      state.pendingDeleteId = null;
      state.items = state.items.filter((i) => i.id !== id);
      
      // Sync with server
      await api.deleteItem(id);
    },
    
    cancelDelete: () => {
      state.pendingDeleteId = null;
    },
  };
}

Summary

ConceptDescription
DefiningReturn functions from setup()
CallingVia useStore, get(), or container.get()
MutationsDirect for first-level, update() for nested
AsyncPlain async or async.action() for advanced
TrackingonDispatch callback, action.last()
CompositionActions can call other actions

Next Steps

Released under the MIT License.