Skip to content

Sharing Store Logic ​

As your application grows, you'll find patterns that repeat across stores. Storion provides several approaches to share and reuse logic effectively.

The Problem ​

When building real applications, you'll encounter these situations:

1. Duplicated Store Structure ​

You need multiple stores with the same shape but for different entity types:

ts
// ❌ PROBLEM: Copy-pasting the same structure
const userStore = store({
  name: "users",
  state: { items: [] as User[], loading: false, selectedId: null },
  setup({ state }) {
    return {
      setItems: (items: User[]) => {
        state.items = items;
      },
      select: (id: string) => {
        state.selectedId = id;
      },
      // ... same logic repeated
    };
  },
});

const productStore = store({
  name: "products",
  state: { items: [] as Product[], loading: false, selectedId: null },
  setup({ state }) {
    return {
      setItems: (items: Product[]) => {
        state.items = items;
      },
      select: (id: string) => {
        state.selectedId = id;
      },
      // ... same logic repeated again
    };
  },
});

// And again for orders, categories, comments...

2. Repeated Cross-Cutting Concerns ​

Every list store needs pagination, selection, or filtering logic:

ts
// ❌ PROBLEM: Same pagination logic in every list store
const userListStore = store({
  state: { items: [] as User[], page: 0, pageSize: 20, total: 0 },
  setup({ state }) {
    return {
      nextPage: () => {
        if ((state.page + 1) * state.pageSize < state.total) state.page++;
      },
      prevPage: () => {
        if (state.page > 0) state.page--;
      },
      setPage: (page: number) => {
        state.page = page;
      },
    };
  },
});

const productListStore = store({
  state: { items: [] as Product[], page: 0, pageSize: 20, total: 0 },
  setup({ state }) {
    return {
      nextPage: () => {
        // Same logic copy-pasted
        if ((state.page + 1) * state.pageSize < state.total) state.page++;
      },
      prevPage: () => {
        if (state.page > 0) state.page--;
      },
      setPage: (page: number) => {
        state.page = page;
      },
    };
  },
});

// Same for selection, filtering, sorting...

Solutions Overview ​

ProblemSolutionDescription
Duplicated structureStore FactoriesSame shape, different types
Repeated concernsMixinsPagination, selection, filtering
Cross-store dataCompositionAccess other stores via get()

Store Factories ​

Solves: Duplicated store structure across entity types.

When multiple stores share the same structure but manage different data, create a factory function:

The Pattern ​

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

// Factory function that creates store specs
function createEntityStore<T extends { id: string }>(name: string) {
  return store({
    name,
    state: {
      items: [] as T[],
      loading: false,
      selectedId: null as string | null,
    },
    setup({ state }) {
      return {
        setItems: (items: T[]) => {
          state.items = items;
        },
        select: (id: string) => {
          state.selectedId = id;
        },
        getSelected: () => {
          return state.items.find((item) => item.id === state.selectedId);
        },
      };
    },
  });
}

// Create specific stores from the factory
const userStore = createEntityStore<User>("users");
const productStore = createEntityStore<Product>("products");
const orderStore = createEntityStore<Order>("orders");

Using Factory Stores ​

tsx
function UserList() {
  const { users, select } = useStore(({ get }) => {
    const [state, actions] = get(userStore);
    return {
      users: state.items,
      select: actions.select,
    };
  });

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id} onClick={() => select(user.id)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

When to Use ​

  • CRUD stores for different entity types
  • Feature stores with identical structure
  • Stores that differ only in their data type

Mixins ​

Solves: Repeated cross-cutting concerns (pagination, selection, filtering).

Mixins let you inject reusable state and actions into any store. They share the store's context.

Creating a Mixin ​

ts
import { store, type SetupContext } from "storion/react";

// Mixin: adds loading/error state and helpers
function withLoadingState<TState extends object>(
  ctx: SetupContext<TState & { loading: boolean; error: string | null }>
) {
  const { state } = ctx;

  return {
    /** Start loading, clear previous error */
    startLoading: () => {
      state.loading = true;
      state.error = null;
    },
    /** Stop loading */
    stopLoading: () => {
      state.loading = false;
    },
    /** Set error and stop loading */
    setError: (error: string) => {
      state.error = error;
      state.loading = false;
    },
    /** Wrap async operation with loading state */
    withLoading: async <T>(operation: () => Promise<T>): Promise<T | null> => {
      state.loading = true;
      state.error = null;
      try {
        const result = await operation();
        state.loading = false;
        return result;
      } catch (e) {
        state.error = e instanceof Error ? e.message : "Unknown error";
        state.loading = false;
        return null;
      }
    },
  };
}

Using Mixins in Stores ​

ts
const userStore = store({
  name: "user",
  state: {
    user: null as User | null,
    // Mixin requires these fields
    loading: false,
    error: null as string | null,
  },
  setup(ctx) {
    const { state } = ctx;
    // Apply mixin via ctx.mixin() - shares same state context
    const loadingActions = ctx.mixin(withLoadingState);

    return {
      ...loadingActions,

      fetchUser: async (id: string) => {
        const user = await loadingActions.withLoading(async () => {
          const res = await fetch(`/api/users/${id}`);
          return res.json();
        });
        if (user) state.user = user;
      },
    };
  },
});

Composing Multiple Mixins ​

ts
// Pagination mixin
function withPagination<TState extends object>(
  ctx: SetupContext<TState & { page: number; pageSize: number; total: number }>
) {
  const { state } = ctx;
  return {
    nextPage: () => {
      if ((state.page + 1) * state.pageSize < state.total) {
        state.page++;
      }
    },
    prevPage: () => {
      if (state.page > 0) state.page--;
    },
    setPage: (page: number) => {
      state.page = page;
    },
  };
}

// Selection mixin - generic T is the item type
function withSelection<T extends { id: string }>(
  ctx: SetupContext<{ items: T[]; selectedIds: Set<string> }>
) {
  const { state, update } = ctx;
  return {
    select: (id: string) => {
      update((draft) => {
        draft.selectedIds.add(id);
      });
    },
    deselect: (id: string) => {
      update((draft) => {
        draft.selectedIds.delete(id);
      });
    },
    toggleSelection: (id: string) => {
      update((draft) => {
        if (draft.selectedIds.has(id)) {
          draft.selectedIds.delete(id);
        } else {
          draft.selectedIds.add(id);
        }
      });
    },
    selectAll: () => {
      update((draft) => {
        state.items.forEach((item) => draft.selectedIds.add(item.id));
      });
    },
    clearSelection: () => {
      update((draft) => {
        draft.selectedIds.clear();
      });
    },
    getSelected: () => {
      return state.items.filter((item) => state.selectedIds.has(item.id));
    },
  };
}

// Combine all mixins
const productListStore = store({
  name: "productList",
  state: {
    items: [] as Product[],
    // Loading mixin
    loading: false,
    error: null as string | null,
    // Pagination mixin
    page: 0,
    pageSize: 20,
    total: 0,
    // Selection mixin
    selectedIds: new Set<string>(),
  },
  setup(ctx) {
    const { state } = ctx;
    const loading = ctx.mixin(withLoadingState);
    const pagination = ctx.mixin(withPagination);
    const selection = ctx.mixin(withSelection<Product>);

    return {
      ...loading,
      ...pagination,
      ...selection,

      fetchProducts: async () => {
        const data = await loading.withLoading(async () => {
          const res = await fetch(
            `/api/products?page=${state.page}&size=${state.pageSize}`
          );
          return res.json();
        });
        if (data) {
          state.items = data.items;
          state.total = data.total;
        }
      },
    };
  },
});

When to Use Mixins ​

  • Cross-cutting concerns (loading, error handling, pagination)
  • Shared behavior that needs access to store context
  • Building blocks that combine to form complete stores

Composition via get() ​

Solves: Cross-store coordination when stores need to access each other.

Stores can access other stores via get() in setup for cross-store coordination.

The Pattern ​

ts
const authStore = store({
  name: "auth",
  state: {
    token: null as string | null,
    user: null as User | null,
  },
  setup({ state }) {
    return {
      setAuth: (token: string, user: User) => {
        state.token = token;
        state.user = user;
      },
      logout: () => {
        state.token = null;
        state.user = null;
      },
    };
  },
});

const apiStore = store({
  name: "api",
  state: {},
  setup({ get }) {
    // Access auth store
    const [authState] = get(authStore);

    return {
      fetch: async (url: string, options?: RequestInit) => {
        const headers = new Headers(options?.headers);

        // Use auth token from auth store
        if (authState.token) {
          headers.set("Authorization", `Bearer ${authState.token}`);
        }

        return fetch(url, { ...options, headers });
      },
    };
  },
});

const userProfileStore = store({
  name: "userProfile",
  state: {
    profile: null as UserProfile | null,
    loading: false,
  },
  setup({ state, get }) {
    const [, apiActions] = get(apiStore);
    const [authState, authActions] = get(authStore);

    return {
      fetchProfile: async () => {
        if (!authState.user) return;

        state.loading = true;
        try {
          const res = await apiActions.fetch(
            `/api/users/${authState.user.id}/profile`
          );
          state.profile = await res.json();
        } finally {
          state.loading = false;
        }
      },

      updateProfile: async (updates: Partial<UserProfile>) => {
        if (!authState.user) return;

        const res = await apiActions.fetch(
          `/api/users/${authState.user.id}/profile`,
          {
            method: "PATCH",
            body: JSON.stringify(updates),
          }
        );
        state.profile = await res.json();
      },
    };
  },
});

Dependency Direction ​

Important

Store dependencies flow in one direction. A keepAlive store cannot depend on an autoDispose store.

ts
// ✅ CORRECT: autoDispose can depend on keepAlive
const sessionStore = store({ lifetime: "keepAlive", ... });
const pageStore = store({
  lifetime: "autoDispose",
  setup({ get }) {
    get(sessionStore); // OK
  }
});

// ❌ WRONG: keepAlive cannot depend on autoDispose
const tempStore = store({ lifetime: "autoDispose", ... });
const globalStore = store({
  lifetime: "keepAlive",
  setup({ get }) {
    get(tempStore); // THROWS!
  }
});

Comparison ​

ApproachShared StateUse Case
FactoryNoSame structure, different types
MixinYesReusable behaviors
CompositionRead-onlyCross-store coordination

Best Practices ​

1. Prefer Mixins for Shared Behaviors ​

ts
// ✅ Good: Mixin for cross-cutting concerns
const loading = withLoadingState(ctx);

// ❌ Avoid: Duplicating loading logic in every store

2. Keep Store Dependencies Shallow ​

ts
// ✅ Good: Direct dependencies
setup({ get }) {
  const [authState] = get(authStore);
}

// ❌ Avoid: Deep dependency chains
setup({ get }) {
  const [a] = get(storeA); // depends on B
  const [b] = get(storeB); // depends on C
  const [c] = get(storeC); // depends on D...
}

3. Document Mixin Requirements ​

ts
/**
 * Adds loading state management.
 * @requires state.loading - boolean
 * @requires state.error - string | null
 */
function withLoadingState<
  TState extends { loading: boolean; error: string | null }
>(ctx: SetupContext<TState>) {
  // ...
}

Summary ​

  • Store Factories: Create multiple stores with the same structure
  • Mixins: Inject reusable actions into any store (same context)
  • Composition: Cross-store access via get() in setup

Choose based on whether you need shared context (mixin) or separate instances (factory).

Released under the MIT License.