Skip to content

useStore

useStore is the primary hook for connecting React components to Storion stores. It handles subscriptions, automatic re-rendering, and provides access to state and actions.

The Basics

tsx
import { useStore } from "storion/react";
import { userStore } from "../stores/userStore";

function UserProfile() {
  const { name, setName } = useStore(({ get }) => {
    const [state, actions] = get(userStore);
    return {
      name: state.name,
      setName: actions.setName,
    };
  });

  return (
    <input
      value={name}
      onChange={(e) => setName(e.target.value)}
    />
  );
}

What happens:

  1. useStore calls your selector function
  2. Selector calls get(userStore) to access the store
  3. Reading state.name creates a subscription to that property
  4. When name changes, component re-renders with new value
  5. Changes to other state properties don't trigger re-renders

The Selector Function

The selector receives a context object and returns what the component needs:

ts
useStore(({ get, mixin, scoped, id, once }) => {
  // get() - Access stores and services
  const [state, actions] = get(userStore);
  const api = get(apiService);

  // mixin() - Use selector mixins
  const [asyncState, asyncActions] = mixin(asyncMixin);

  // scoped() - Component-local store instances
  const [formState, formActions] = scoped(formStore);

  // id - Unique ID for this component instance
  console.log("Component ID:", id);

  // once() - Run code once on mount
  once(() => {
    console.log("Component mounted");
  });

  // Return what the component needs
  return { name: state.name, setName: actions.setName };
});

Accessing Multiple Stores

Combine data from multiple stores in a single selector:

tsx
function Dashboard() {
  const { userName, todoCount, cartTotal } = useStore(({ get }) => {
    const [userState] = get(userStore);
    const [todoState] = get(todoStore);
    const [cartState] = get(cartStore);

    return {
      userName: userState.name,
      todoCount: todoState.items.length,
      cartTotal: cartState.items.reduce((sum, i) => sum + i.price, 0),
    };
  });

  return (
    <div>
      <h1>Welcome, {userName}</h1>
      <p>{todoCount} todos | ${cartTotal} in cart</p>
    </div>
  );
}

Computed Values

Derive values in the selector - they're recalculated on each render:

tsx
function TodoStats() {
  const { completed, total, progress } = useStore(({ get }) => {
    const [state] = get(todoStore);

    const completed = state.items.filter((t) => t.done).length;
    const total = state.items.length;

    return {
      completed,
      total,
      progress: total > 0 ? Math.round((completed / total) * 100) : 0,
    };
  });

  return (
    <div>
      {completed}/{total} completed ({progress}%)
    </div>
  );
}

Optimizing Re-renders

Return Only What You Need

The most important optimization is returning only what your component uses:

tsx
// ❌ Bad - returns entire state, re-renders on ANY change
const { state } = useStore(({ get }) => {
  const [state] = get(userStore);
  return { state }; // Tracks everything!
});

// ✅ Good - returns specific values, re-renders only when they change
const { name, email } = useStore(({ get }) => {
  const [state] = get(userStore);
  return { name: state.name, email: state.email };
});

Use pick() for Fine-grained Control

For collections or nested objects, use pick() to control exactly what triggers re-renders:

tsx
import { pick } from "storion";

function TodoCount() {
  const { count } = useStore(({ get }) => {
    const [state] = get(todoStore);
    return {
      // Only re-renders when items.length changes
      // Not when item content changes
      count: pick(() => state.items.length),
    };
  });

  return <span>{count} items</span>;
}

Custom Equality with pick()

tsx
import { pick, shallowEqual, deepEqual } from "storion";

const result = useStore(({ get }) => {
  const [state] = get(todoStore);
  return {
    // Strict equality (default)
    filter: state.filter,

    // Shallow equality - good for arrays
    items: pick(() => state.items, "shallow"),

    // Deep equality - good for nested objects
    settings: pick(() => state.settings, "deep"),

    // Custom comparison
    ids: pick(
      () => state.items.map((i) => i.id),
      (a, b) => a.join(",") === b.join(",")
    ),
  };
});

Stable Function References

Functions returned from useStore are automatically wrapped with stable references:

tsx
function SearchForm({ userId }) {
  const [query, setQuery] = useState("");

  const { results, search } = useStore(({ get }) => {
    const [state, actions] = get(searchStore);
    return {
      results: state.results,
      // This function reference is STABLE
      // It won't change between renders
      // But it always has access to current query and userId
      search: () => actions.search(query, userId),
    };
  });

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {/* Safe to pass to memoized children */}
      <MemoizedButton onClick={search}>Search</MemoizedButton>
      <Results items={results} />
    </div>
  );
}

// Button never re-renders due to onClick changes
const MemoizedButton = memo(({ onClick, children }) => (
  <button onClick={onClick}>{children}</button>
));

Why this matters:

  • No need for useCallback - functions are stable automatically
  • Safe to pass to memo() components without causing re-renders
  • Always has access to current props and state

Triggering Actions

Use trigger() for effects that should run based on dependencies:

tsx
import { trigger } from "storion";

function UserProfile({ userId }) {
  const { user } = useStore(({ get }) => {
    const [state, actions] = get(userStore);

    // Fetch when userId changes
    trigger(actions.fetchUser, [userId], userId);

    return { user: state.user };
  });

  return <div>{user.data?.name}</div>;
}

trigger() patterns:

PatternBehavior
trigger(fn, [])Run once, ever
trigger(fn, [id])Run every mount (id is unique)
trigger(fn, [userId], userId)Run when userId changes
No triggerUser controls when to call

Important

Never pass anonymous functions to trigger():

ts
// ❌ WRONG - new function each render = infinite calls
trigger(() => actions.fetch(id), [id]);

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

Component-Local State with scoped()

Use scoped() for stores that should be isolated per component:

tsx
function FormComponent() {
  const { value, setValue, reset } = useStore(({ get, scoped }) => {
    // Global store - shared across components
    const [userState] = get(userStore);

    // Component-local store - isolated, auto-disposed on unmount
    const [formState, formActions] = scoped(formStore);

    return {
      value: formState.value,
      setValue: formActions.setValue,
      reset: formActions.reset,
    };
  });

  return (
    <div>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <button onClick={reset}>Reset</button>
    </div>
  );
}

When to use scoped():

  • Form state that shouldn't be shared
  • Modal/dialog internal state
  • Temporary UI state
  • Any state that should reset when component unmounts

Selector Mixins vs Multiple Hooks

When selecting data from multiple stores, you have two options: multiple useStore() hooks or selector mixins. Selector mixins are more efficient.

The Problem with Multiple useStore() Calls

tsx
// ❌ INEFFICIENT: Multiple hooks = multiple subscriptions, lifecycle overhead
const useUserData = () => {
  return useStore(({ get }) => {
    const [state] = get(userStore);
    return { name: state.name };
  });
};

const useCartData = () => {
  return useStore(({ get }) => {
    const [state] = get(cartStore);
    return { itemCount: state.items.length };
  });
};

// Component uses both hooks
function Header() {
  const { name } = useUserData();     // 1st useStore (subscriptions, refs, effects)
  const { itemCount } = useCartData(); // 2nd useStore (more subscriptions, refs, effects)
  
  return <div>{name} - Cart: {itemCount}</div>;
}

What's wrong:

  • Each useStore() creates its own subscription system
  • Duplicate React hooks (useState, useEffect, etc.) for each call
  • If both selectors read the same field, tracking is duplicated
  • More memory, more cleanup, slower renders

The Solution: Selector Mixins

tsx
// ✅ EFFICIENT: Single useStore with composable mixins
const userDataMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return { name: state.name };
};

const cartDataMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(cartStore);
  return { itemCount: state.items.length };
};

// Combine mixins into one
const headerDataMixin = (ctx: SelectorContext) => {
  const userData = ctx.mixin(userDataMixin);
  const cartData = ctx.mixin(cartDataMixin);
  return { ...userData, ...cartData };
};

// Component uses single hook
function Header() {
  const { name, itemCount } = useStore((ctx) => ctx.mixin(headerDataMixin));
  
  return <div>{name} - Cart: {itemCount}</div>;
}

Benefits:

  • Single subscription system for all data
  • One set of React hooks
  • Shared tracking — same fields aren't tracked twice
  • Composable and reusable

Comparison

AspectMultiple useStore()Selector Mixins
React hooksN sets (useState, useEffect, etc.)1 set
SubscriptionsN separate systems1 unified system
TrackingDuplicate for same fieldsShared tracking
MemoryHigherLower
ComposabilityVia custom hooksVia mixin composition

Creating Selector Mixins

ts
import { SelectorContext } from "storion/react";

// Basic mixin - selects specific data
const userNameMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return state.name;
};

// Mixin with parameters
const userByIdMixin = (ctx: SelectorContext, userId: string) => {
  const [state] = ctx.get(userStore);
  return state.users[userId];
};

// Mixin returning multiple values
const userStatsMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return {
    totalUsers: state.users.length,
    activeUsers: state.users.filter(u => u.active).length,
  };
};

// Mixin with actions
const cartMixin = (ctx: SelectorContext) => {
  const [state, actions] = ctx.get(cartStore);
  return {
    items: state.items,
    total: state.total,
    addItem: actions.addItem,
    removeItem: actions.removeItem,
  };
};

Composing Mixins

ts
// Combine multiple mixins
const dashboardMixin = (ctx: SelectorContext) => {
  const user = ctx.mixin(userStatsMixin);
  const cart = ctx.mixin(cartMixin);
  const orders = ctx.mixin(ordersMixin);
  
  return {
    ...user,
    ...cart,
    ...orders,
    // Add computed values
    isVIP: user.totalUsers > 100 && cart.total > 1000,
  };
};

function Dashboard() {
  const data = useStore((ctx) => ctx.mixin(dashboardMixin));
  
  return (
    <div>
      <UserStats {...data} />
      <CartSummary {...data} />
      <OrderHistory {...data} />
    </div>
  );
}

Module Organization

ts
// selectors/user.ts
export const userNameMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return state.name;
};

export const userProfileMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return {
    name: state.name,
    email: state.email,
    avatar: state.avatar,
  };
};

// selectors/cart.ts
export const cartSummaryMixin = (ctx: SelectorContext) => {
  const [state, actions] = ctx.get(cartStore);
  return {
    itemCount: state.items.length,
    total: state.total,
    checkout: actions.checkout,
  };
};

// selectors/combined.ts
import { userProfileMixin } from "./user";
import { cartSummaryMixin } from "./cart";

export const headerMixin = (ctx: SelectorContext) => ({
  ...ctx.mixin(userProfileMixin),
  ...ctx.mixin(cartSummaryMixin),
});

When to Use Each Approach

ScenarioRecommendation
Single store accessEither approach works
Multiple stores, same component✅ Selector mixins
Reusable selection logic✅ Selector mixins
Isolated hook with own stateCustom hook with useStore
Third-party hook integrationCustom hook with useStore

Shorthand: mixins() Helper

For cleaner mixin composition, use the mixins() helper:

Array syntax — Merge multiple mixins:

tsx
import { useStore, mixins } from "storion/react";

// Direct mixin returns object that gets spread
const selectUser = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return { name: state.name, email: state.email };
};

// Named mixin maps key to result
const selectCount = (ctx: SelectorContext) => {
  const [state] = ctx.get(counterStore);
  return state.count;
};

function Component() {
  // Array merges all results
  const { name, email, count } = useStore(
    mixins([
      selectUser,            // → { name, email } spread into result
      { count: selectCount } // → { count: number }
    ])
  );
  
  return <div>{name}: {count}</div>;
}

Object syntax — Map keys to mixin results (keys ending with "Mixin" are stripped):

tsx
const selectNameMixin = (ctx: SelectorContext) => ctx.get(userStore)[0].name;
const selectAgeMixin = (ctx: SelectorContext) => ctx.get(userStore)[0].age;
const selectIncrement = (ctx: SelectorContext) => ctx.get(counterStore)[1].increment;

function Component() {
  // Each key maps to its mixin's return value
  // "Mixin" suffix stripped: selectNameMixin → selectName
  const { selectName, selectAge, inc } = useStore(
    mixins({
      selectNameMixin,   // string
      selectAgeMixin,    // number  
      inc: selectIncrement,   // () => void
    })
  );
  
  return (
    <div>
      <p>{selectName}, age {selectAge}</p>
      <button onClick={inc}>+</button>
    </div>
  );
}

Comparison:

tsx
// Standard selector - most flexible
const data = useStore((ctx) => ({
  ...ctx.mixin(userMixin),
  ...ctx.mixin(cartMixin),
}));

// mixins() array - cleaner composition
const data = useStore(mixins([userMixin, cartMixin]));

// mixins() object - explicit key mapping
const data = useStore(
  mixins({
    user: userMixin,
    cart: cartMixin,
  })
);

StoreSpec proxy — Get a proxy for all store properties/actions:

tsx
import { mixins, store } from "storion/react";

const userStore = store({
  state: { name: "", age: 0 },
  setup: ({ state }) => ({
    setName: (name: string) => { state.name = name; },
  }),
});

const proxy = mixins(userStore);

function Component() {
  // Use proxy to access state/actions as mixins
  const { name, setName } = useStore(
    mixins({
      name: proxy.name,
      setName: proxy.setName,
    })
  );
  
  // Or use select() to select multiple at once
  const { name, age, setName } = useStore(
    proxy.select(["name", "age", "setName"])
  );
}

Service factory proxy — Get a proxy for service properties:

tsx
const dbService = (resolver: Resolver) => ({
  users: { getAll: () => [] },
});

const proxy = mixins(dbService);

function Component() {
  const { users } = useStore(
    mixins({
      users: proxy.users,
    })
  );
}

See useStore() API reference for more details.

Real-World Example

tsx
// ❌ BEFORE: Three separate hooks
function ProductPage({ productId }: { productId: string }) {
  const { product } = useProduct(productId);
  const { user } = useCurrentUser();
  const { cart, addToCart } = useCart();
  
  // Each hook runs its own useStore internally...
}

// ✅ AFTER: Single useStore with mixins
const productMixin = (ctx: SelectorContext, productId: string) => {
  const [state] = ctx.get(productStore);
  return { product: state.products[productId] };
};

const currentUserMixin = (ctx: SelectorContext) => {
  const [state] = ctx.get(userStore);
  return { user: state.currentUser };
};

const cartMixin = (ctx: SelectorContext) => {
  const [state, actions] = ctx.get(cartStore);
  return { cart: state, addToCart: actions.add };
};

function ProductPage({ productId }: { productId: string }) {
  const { product, user, cart, addToCart } = useStore((ctx) => ({
    ...ctx.mixin(productMixin, productId),
    ...ctx.mixin(currentUserMixin),
    ...ctx.mixin(cartMixin),
  }));
  
  // Single subscription system, shared tracking, lower overhead
}

Using Async Mixins

For async operations, use async.mixin() which provides component-local async state:

tsx
import { async } from "storion/async";

// Define a mixin for async operations
const submitMixin = async(async (ctx, data: FormData) => {
  const res = await fetch("/api/submit", {
    method: "POST",
    body: JSON.stringify(data),
    signal: ctx.signal,
  });
  return res.json();
});

function ContactForm() {
  const { status, error, submit } = useStore(({ mixin }) => {
    const [state, actions] = mixin(submitMixin);
    return {
      status: state.status,
      error: state.error,
      submit: actions.dispatch,
    };
  });

  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(data); }}>
      <button disabled={status === "pending"}>Submit</button>
      {status === "error" && <p>{error.message}</p>}
    </form>
  );
}

Requirements

useStore must be used within a StoreProvider:

tsx
import { container, StoreProvider } from "storion/react";

const app = container();

function App() {
  return (
    <StoreProvider container={app}>
      <YourApp /> {/* useStore works anywhere in this tree */}
    </StoreProvider>
  );
}

Common Patterns

Loading States

tsx
function UserProfile({ userId }) {
  const { user, fetchUser } = useStore(({ get }) => {
    const [state, actions] = get(userStore);
    trigger(actions.fetchUser, [userId], userId);
    return { user: state.user, fetchUser: actions.fetchUser };
  });

  if (user.status === "idle") return <button onClick={() => fetchUser(userId)}>Load</button>;
  if (user.status === "pending") return <Spinner />;
  if (user.status === "error") return <Error error={user.error} />;
  return <div>{user.data.name}</div>;
}

Conditional Store Access

tsx
function MaybeAdmin({ isAdmin }) {
  const result = useStore(({ get }) => {
    const [userState] = get(userStore);

    // Only access admin store if user is admin
    if (isAdmin) {
      const [adminState] = get(adminStore);
      return { user: userState, admin: adminState };
    }

    return { user: userState, admin: null };
  });

  return <div>...</div>;
}

Accessing Services

tsx
function DataView() {
  const { data, refresh } = useStore(({ get }) => {
    const [state, actions] = get(dataStore);
    const logger = get(loggerService); // Services work too

    return {
      data: state.data,
      refresh: () => {
        logger.info("Refreshing data");
        actions.refresh();
      },
    };
  });
}

Pre-bound Hooks with useStore.from()

For components that primarily work with a single store, useStore.from() creates a simpler hook:

tsx
import { useStore } from "storion/react";
import { counterStore } from "../stores/counterStore";

// Create a pre-bound hook
const useCounter = useStore.from(counterStore);

function Counter() {
  // State and actions are provided directly - no get() needed
  const { count, increment } = useCounter((state, actions) => ({
    count: state.count,
    increment: actions.increment,
  }));

  return <button onClick={increment}>{count}</button>;
}

When to Use

PatternUse Case
useStore()Multiple stores, complex selectors
useStore.from(spec)Single store focus, simpler syntax

Module Pattern

Export the pre-bound hook alongside your store for cleaner imports:

ts
// stores/counter.ts
export const counterStore = store({
  name: "counter",
  state: { count: 0 },
  setup: ({ state }) => ({
    increment: () => { state.count++; },
  }),
});

export const useCounter = useStore.from(counterStore);
tsx
// components/Counter.tsx
import { useCounter } from "../stores/counter";

function Counter() {
  const { count, increment } = useCounter((state, actions) => ({
    count: state.count,
    increment: actions.increment,
  }));
  
  return <button onClick={increment}>{count}</button>;
}

Accessing Other Stores

The third parameter provides full context access:

tsx
const useCounter = useStore.from(counterStore);

function UserCounter() {
  const { count, userName } = useCounter((state, actions, ctx) => {
    const [userState] = ctx.get(userStore);
    return {
      count: state.count,
      userName: userState.name,
    };
  });
}

Parameterized Hooks

Create hooks with arguments using the selector overload:

tsx
// Create a parameterized hook
const useUserById = useStore.from((ctx, userId: string) => {
  const [state] = ctx.get(userStore);
  return { user: state.users[userId] };
});

// Use with different arguments
function UserCard({ userId }: { userId: string }) {
  const { user } = useUserById(userId);
  return <div>{user?.name}</div>;
}

See useStore.from() API reference for more details.

Selector Context Reference

PropertyTypeDescription
get(spec) => [state, actions] or TAccess stores and services
mixin(mixin, ...args) => resultUse selector mixins
scoped(spec) => [state, actions, instance]Component-local store instances
idobjectUnique ID per component mount
once(fn) => voidRun function once on mount

See Also

Released under the MIT License.