portal

@ibnlanre/portal

npm version Build Status License: BSD-3-Clause

@ibnlanre/portal is a state management library designed to bridge the gap between simple state hooks and complex state machines. It provides an intuitive, type-safe approach to state management that grows with your application’s needs.

Perfect for teams that:

Whether you’re building a small React component or a large-scale application, @ibnlanre/portal adapts to your needs without forcing specific architectural patterns or unnecessary complexity.

Table of contents

Features

@ibnlanre/portal offers powerful features for efficient state management:

Get started

This section guides you through setting up @ibnlanre/portal in your project.

Prerequisites

Before you begin, ensure your development environment includes:

Install the library

You can add @ibnlanre/portal to your project using a package manager or by including it from a CDN.

Using a package manager

  1. Navigate to your project directory in the terminal.
  2. Run one of the following commands, depending on your package manager:

    npm

    npm install @ibnlanre/portal
    

    pnpm

    pnpm add @ibnlanre/portal
    

    yarn

    yarn add @ibnlanre/portal
    

    The library includes TypeScript definitions, so no separate @types package is needed.

Using a CDN

For projects that don’t use a package manager (e.g., simple HTML pages or online playgrounds), you can include @ibnlanre/portal from a CDN:

Skypack

<script type="module">
  import { createStore } from "https://cdn.skypack.dev/@ibnlanre/portal";
  // Use createStore and other exports here
</script>

unpkg

<script src="https://unpkg.com/@ibnlanre/portal"></script>
<!-- The library will be available globally, e.g., window.Portal.createStore -->

jsDelivr

<script src="https://cdn.jsdelivr.net/npm/@ibnlanre/portal"></script>
<!-- The library will be available globally, e.g., window.Portal.createStore -->

Quick start

Get up and running with @ibnlanre/portal in 5 minutes.

Your first store

import { z } from "zod";
import { createStore } from "@ibnlanre/portal";

// Create a simple store
const countStore = createStore(z.number(), 0);

// Get the current value
console.log(countStore.$get()); // 0

// Update the value
countStore.$set(5);
countStore.$set((prev) => prev + 1); // 6

// Subscribe to changes
countStore.$subscribe((newValue) => {
  console.log("Count updated to:", newValue);
});

Stores with nested state

import { z } from "zod";
import { createStore } from "@ibnlanre/portal";

const userStore = createStore(
  z.object({
    name: z.string(),
    email: z.string(),
    preferences: z.object({
      theme: z.string(),
      notifications: z.boolean(),
    }),
  }),
  {
    name: "Alex",
    email: "alex@example.com",
    preferences: { theme: "dark", notifications: true },
  }
);

// Access nested values
console.log(userStore.preferences.theme.$get()); // "dark"

// Update nested properties (partial updates)
userStore.preferences.$set({ theme: "light" }); // notifications remains true

Using stores in React

import { z } from "zod";
import { createStore } from "@ibnlanre/portal";
import { useState } from "react";

const counterStore = createStore(z.object({ count: z.number() }), { count: 0 });

function Counter() {
  // Use the $use hook like useState
  const [state, setState] = counterStore.$use();

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => setState({ count: state.count + 1 })}>
        Increment
      </button>
    </div>
  );
}

For more comprehensive guides, continue to the Understand core concepts section below.

Understand core concepts

Understanding these core concepts will help you use @ibnlanre/portal effectively.

What is a store?

A store is an object that holds your application’s state. It allows you to read the state, update it, and subscribe to changes. @ibnlanre/portal stores can hold any kind of data, from simple primitive values to complex, nested objects.

Store types: Primitive and Composite

Store Type Description Example Initial State
Primitive Store Manages a single primitive value (string, number, boolean, null, undefined) 0, "Alex", true
Composite Store Manages an object with nested properties, each as a sub-store { name: "Alex", age: 30 }

@ibnlanre/portal distinguishes between two main types of stores, created automatically based on the initial state you provide:

  1. Primitive Store: Manages a single, primitive value (e.g., a string, number, boolean, null, or undefined). At times, it can also manage a single object as a primitive-like store, where the entire object is treated as a single value.
    • Example: A store holding a user’s name as a string or a count as a number.
    • Primitive stores provide methods to get the current value, set a new value, and subscribe to changes.
  2. Composite Store: Manages an object, enabling nested state structures. Each property in a composite store’s initial object can itself become a store instance (either primitive or composite), allowing for granular state management and access.
    • Example: A store holding user details, where each property (like name, email, address) can be accessed and updated independently.
    • Composite stores provide methods to get the current state, set new values for specific properties, and subscribe to changes at any level of the nested structure.

Both store types share a consistent API for getting, setting, and subscribing to state.

Immutability and reactivity

@ibnlanre/portal embraces immutability. When you update the state, the library creates a new state object instead of modifying the existing one. This helps prevent bugs and makes state changes predictable.

Tip: Stores are reactive. When a store’s state changes, any components or subscribers listening to that store (or its parts) are notified, allowing your UI to update automatically.

Configure your stores

@ibnlanre/portal is designed to work with minimal configuration. The primary configuration points are:

  1. Schema: When you call createStore(), you provide a schema as the first argument. The schema defines the type and structure of your state. Use any Standard Schema compatible library (Zod, Valibot, ArkType, etc.).
  2. Initial State: The second argument provides the starting value. If the schema supplies defaults (e.g., z.number().default(0)), this argument is optional.
  3. Persistence Adapters: If you use state persistence, you configure adapters with options like storage keys and serialization functions.

Refer to the Persist state section for detailed configuration of each adapter.

Use the API: Reference and examples

This section provides a comprehensive reference for the @ibnlanre/portal API, with detailed explanations and examples.

Create stores: createStore()

The createStore() function is the primary way to initialize a new store. For specific scenarios or finer control, you can also use the direct store creation functions createPrimitiveStore() and createCompositeStore().

Using createStore()

Syntax:

// Schema with explicit initial state
createStore<Schema, State>(schema: Schema, initialState: State): Store<State>

// Schema with built-in defaults (no initialState needed)
createStore<Schema>(schema: Schema): Store<InferSchema<Schema>>

Examples:

  1. Creating a primitive store:

    import { z } from "zod";
    import { createStore } from "@ibnlanre/portal";
    
    const countStore = createStore(z.number(), 0);
    console.log(countStore.$get()); // Output: 0
    
    // Schema defaults — no initialState needed
    const messageStore = createStore(z.string().default("Hello, world!"));
    console.log(messageStore.$get()); // Output: "Hello, world!"
    
  2. Creating a composite store:

    import { z } from "zod";
    import { createStore } from "@ibnlanre/portal";
    
    const userStore = createStore(
      z.object({
        id: z.number(),
        name: z.string(),
        email: z.string(),
        address: z.object({
          street: z.string(),
          city: z.string(),
        }),
      }),
      {
        id: 1,
        name: "Alex Johnson",
        email: "alex@example.com",
        address: { street: "123 Main St", city: "Anytown" },
      }
    );
    
    console.log(userStore.name.$get()); // Output: "Alex Johnson"
    console.log(userStore.address.city.$get()); // Output: "Anytown"
    
  3. Creating a store with asynchronous initialization:

    import { z } from "zod";
    import { createStore } from "@ibnlanre/portal";
    
    async function fetchUserData(): Promise<{ id: number; name: string }> {
      return new Promise((resolve) => {
        setTimeout(() => resolve({ id: 1, name: "Fetched User" }), 1000);
      });
    }
    
    // Pass an async schema default to createStore
    const userProfileStore = await createStore(
      z.custom<{ id: number; name: string }>(),
      fetchUserData()
    );
    
    console.log(userProfileStore.$get()); // Output: { id: 1, name: "Fetched User" }
    

Using createPrimitiveStore()

Creates a store specifically for a single, primitive value.

Syntax:

createPrimitiveStore<Schema, State>(schema: Schema, initialState: State): PrimitiveStore<State>

When to use:

Example:

import { z } from "zod";
import { createPrimitiveStore } from "@ibnlanre/portal";

const isActiveStore = createPrimitiveStore(z.boolean(), false);
console.log(isActiveStore.$get()); // false

Using createCompositeStore()

Creates a store specifically for an object, enabling nested state structures.

Syntax:

createCompositeStore<Schema, State>(schema: Schema, initialState: State): CompositeStore<State>

When to use:

Example:

import { z } from "zod";
import { createCompositeStore } from "@ibnlanre/portal";

const userDetailsStore = createCompositeStore(
  z.object({
    username: z.string(),
    permissions: z.object({ read: z.boolean(), write: z.boolean() }),
  }),
  { username: "guest", permissions: { read: true, write: false } }
);

console.log(userDetailsStore.username.$get()); // "guest"
userDetailsStore.permissions.write.$set(true);

Note: Using createStore() is generally preferred as it automatically determines whether to create a primitive or composite store based on the initial state.

Create context stores: createContextStore()

The createContextStore() function creates React Context-based stores that solve a common problem: initializing stores with dynamic values that come from props or external sources. This is particularly useful when you need to create stores that depend on runtime data rather than static initial values.

Key Benefits:

The Problem it Solves:

Global stores are typically created outside of the React component lifecycle, so they can’t be initialized with values from props or context. With a global store, you’d need to:

  1. Create the store with a known default state
  2. Sync props to the store using useEffect in every component that needs it

createContextStore eliminates this boilerplate by allowing you to pass a function that receives the context data needed before the store is initialized.

Syntax:

createContextStore<Context, ContextStore>(
  initializer: (context: Context) => ContextStore
): [StoreProvider, useStore]

Basic Example:

import { z } from "zod";
import { createContextStore, createStore } from "@ibnlanre/portal";

interface UserProps {
  userId: string;
  theme: "light" | "dark";
}

// Create a context store for user settings
const [UserProvider, useUserStore] = createContextStore(
  (context: UserProps) => {
    return createStore(
      z.object({
        userId: z.string(),
        theme: z.enum(["light", "dark"]),
      }),
      context
    );
  }
);

function UserProfile() {
  const store = useUserStore();
  const { userId, theme } = store.$get();

  return (
    <div style=>
      <p>User ID: {userId}</p>
      <p>Theme: {theme}</p>
    </div>
  );
}

function App(props: UserProps) {
  return (
    <UserProvider value={props}>
      {/* The UserProfile component can now access the userId and theme from the context */}
      <UserProfile />
    </UserProvider>
  );
}

<App userId="123" theme="dark" />;

Advanced Example with Actions:

import { createContextStore, createStore, combine } from "@ibnlanre/portal";

type CounterContext = { initialCount: number };

const [CounterProvider, useCounterStore] = createContextStore(
  (context: CounterContext) => {
    const store = useSync(() => {
      const initialState = { count: context.initialCount };

      const actions = {
        increment() {
          counterStore.count.$set((prev: number) => prev + 1);
        },
        decrement() {
          counterStore.count.$set((prev: number) => prev - 1);
        },
        reset() {
          counterStore.count.$set(context.initialCount);
        },
      };

      const counterStore = createStore(
        z.object({ count: z.number() }),
        combine(initialState, actions)
      );
      return counterStore;
    }, [context.initialCount]);

    return store;
  }
);

function Counter() {
  const store = useCounterStore();
  const [count] = store.count.$use();

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

function App() {
  return (
    <CounterProvider value=>
      <Counter />
    </CounterProvider>
  );
}

Use Cases:

Note: The factory function passed to createContextStore runs on every render. You’re responsible for memoizing values if needed. This design gives you complete freedom to use any hooks or memoization strategy within the factory function.

Use store instance methods

All store instances, whether primitive or composite, provide a core set of methods for interacting with the state.

Quick Reference Table:

Method Use Case Returns
$get() Retrieve current state or compute a derived value S or derived value R
$set() Update state with a new value or updater function void
$subscribe() Subscribe to state changes and react to updates unsubscribe function
$use() Connect store to React component (hook) [state, setState] tuple
$at() Access deeply nested stores using dot-separated paths (Composite) Nested Store instance

$get()

Retrieves the current state of the store. Optionally, you can provide a selector function to compute a derived value from the state without altering the stored state.

Syntax:

$get(): S
$get<R>(selector: (currentState: S) => R): R

Examples:

  1. Getting the current state:

    const countStore = createStore(z.number(), 10);
    const currentCount = countStore.$get(); // 10
    
    const userStore = createStore(
      z.object({ name: z.string(), role: z.string() }),
      { name: "Alex", role: "admin" }
    );
    const currentUser = userStore.$get(); // { name: "Alex", role: "admin" }
    const userName = userStore.name.$get(); // "Alex"
    
  2. Getting a derived value using a selector:

    const countStore = createStore(z.number(), 10);
    const doubledCount = countStore.$get((count) => count * 2); // 20
    console.log(countStore.$get()); // 10 (original state is unchanged)
    
    const userStore = createStore(
      z.object({ firstName: z.string(), lastName: z.string() }),
      { firstName: "Alex", lastName: "Johnson" }
    );
    const fullName = userStore.$get(
      (user) => `${user.firstName} ${user.lastName}`
    ); // "Alex Johnson"
    

$set()

Updates the store’s state. You can pass a new value directly or provide an update function that receives the previous state and returns the new state.

For composite stores holding objects, $set performs a deep partial update. This means you only need to provide the properties you want to change, and @ibnlanre/portal will merge them intelligently with the existing state.

Syntax:

$set(newValue: S): void
$set(updater: (prevState: S) => S): void

Examples:

  1. Setting a new value directly (Primitive Store):

    const countStore = createStore(z.number(), 0);
    countStore.$set(5);
    console.log(countStore.$get()); // 5
    
  2. Updating using a function (Primitive Store):

    const countStore = createStore(z.number(), 5);
    countStore.$set((prevCount) => prevCount + 1);
    console.log(countStore.$get()); // 6
    
  3. Partial update on a Composite Store:

    const settingsStore = createStore(
      z.object({
        theme: z.string(),
        fontSize: z.number(),
        notifications: z.boolean(),
      }),
      { theme: "light", fontSize: 12, notifications: true }
    );
    
    // Update only theme and fontSize; notifications is preserved.
    settingsStore.$set({ theme: "dark", fontSize: 14 });
    // settingsStore.$get() is now { theme: "dark", fontSize: 14, notifications: true }
    
    // Functional partial update
    settingsStore.$set((prevSettings) => ({
      ...prevSettings, // Spread previous settings to preserve unspecified ones
      fontSize: prevSettings.fontSize + 2, // Only update fontSize
    }));
    // settingsStore.$get() is now { theme: "dark", fontSize: 16, notifications: true }
    
  4. Updating nested properties in a Composite Store:

    const userStore = createStore(
      z.object({
        profile: z.object({ name: z.string(), age: z.number() }),
        role: z.string(),
      }),
      { profile: { name: "Alex", age: 30 }, role: "user" }
    );
    
    // Update nested property directly
    userStore.profile.name.$set("Alexandra");
    console.log(userStore.profile.name.$get()); // "Alexandra"
    
    // Update part of the nested object
    userStore.profile.$set({ age: 31 }); // name is preserved
    // userStore.profile.$get() is { name: "Alexandra", age: 31 }
    

Note on arrays: When a part of your state is an array, and you use $set on the parent object containing that array, the entire array will be replaced if it’s part of the update object. To modify array elements (e.g., add or remove items), access the array store directly or use functional updates on that specific array store.

const listStore = createStore(
  z.object({ items: z.array(z.number()), name: z.string() }),
  { items: [1, 2, 3], name: "My List" }
);

// This replaces the entire 'items' array but preserves 'name'.
listStore.$set({ items: [4, 5, 6] });
// listStore.$get() is { items: [4, 5, 6], name: "My List" }

// To add an item, update the 'items' store directly.
listStore.items.$set((prevItems) => [...prevItems, 7]);
// listStore.items.$get() is now [4, 5, 6, 7]

$subscribe()

Subscribes a callback function to state changes. The callback receives the new state (and optionally the old state) whenever it changes. This method returns an unsubscribe function to stop listening for updates.

By default, the callback is invoked immediately with the current state upon subscription. To prevent this initial invocation, pass false as the second argument.

Syntax:

$subscribe(subscriber: (newState: S, oldState?: S) => void, immediate?: boolean): () => void

Examples:

  1. Basic subscription:

    const nameStore = createStore(z.string(), "Alex");
    
    const unsubscribe = nameStore.$subscribe((newName, oldName) => {
      console.log(`Name changed from "${oldName}" to "${newName}"`);
    });
    // Immediately logs: Name changed from "undefined" to "Alex"
    // (oldState is undefined on the initial call if immediate: true)
    
    nameStore.$set("Jordan"); // Logs: Name changed from "Alex" to "Jordan"
    
    unsubscribe(); // Stop listening to changes
    nameStore.$set("Casey"); // Nothing is logged
    
  2. Subscription without immediate callback execution:

    const statusStore = createStore(z.string(), "idle");
    
    const unsubscribeNonImmediate = statusStore.$subscribe((newStatus) => {
      console.log(`Status updated to: ${newStatus}`);
    }, false); // `false` prevents immediate call
    
    statusStore.$set("active"); // Logs: "Status updated to: active"
    
    // Unsubscribe to stop listening
    unsubscribeNonImmediate();
    
  3. Subscribing to a composite store:

    const settingsStore = createStore(
      z.object({ theme: z.string(), volume: z.number() }),
      { theme: "light", volume: 70 }
    );
    
    // Setting up subscription to changes in settings
    const unsubscribeSettings = settingsStore.$subscribe((newSettings) => {
      console.log("Settings updated:", newSettings);
    });
    
    // Changing the theme triggers the subscription
    settingsStore.theme.$set("dark");
    
    // Logs: Settings updated: { theme: "dark", volume: 70 }
    unsubscribeSettings(); // Stop listening to changes
    

$at()

(CompositeStore only) Provides convenient access to deeply nested stores using a dot-separated string path. This method returns the nested store instance, allowing you to use its methods ($get, $set, $subscribe, $use, $at) directly.

Syntax:

$at<N extends Store<any>>(path: string): N

Examples:

const appStore = createStore(
  z.object({
    user: z.object({
      profile: z.object({ name: z.string(), email: z.string() }),
      preferences: z.object({ theme: z.string(), language: z.string() }),
    }),
    status: z.string(),
  }),
  {
    user: {
      profile: { name: "Alex", email: "alex@example.com" },
      preferences: { theme: "dark", language: "en" },
    },
    status: "active",
  }
);

// Access nested stores using $at
const themeStore = appStore.$at("user.preferences.theme");

// Immediately get the current theme
console.log(themeStore.$get()); // "dark"

// Instantly update the theme
themeStore.$set("light");

// The update is reflected in the original store
console.log(appStore.user.preferences.theme.$get()); // "light"

$at can be used on intermediate stores as well. For example, if you want to access a nested property like user.preferences.language, you can do so directly:

// Accessing a nested store using $at
const preferencesStore = appStore.user.$at("preferences");

// Equivalent to appStore.$at("user.preferences.language")
const languageStore = preferencesStore.$at("language");
console.log(languageStore.$get()); // "en"

// Using methods on the store returned by $at
const unsubscribe = appStore
  .$at("user.preferences.theme")
  .$subscribe((newTheme) => {
    console.log("Theme via $at:", newTheme);
  });

// Triggers the subscription
appStore.user.preferences.theme.$set("blue");
unsubscribe();

$use() (React Hook)

Connects your React components to an @ibnlanre/portal store. It works like React’s useState hook, returning a tuple with the current state value (or a derived value) and a function to update the store’s state.

The $use hook automatically subscribes the component to store changes and unsubscribes when the component unmounts, ensuring efficient re-renders.

Syntax:

$use(): [S, (newValue: S | ((prevState: S) => S)) => void]
$use<R>(
  selector: (currentState: S) => R,
  dependencies?: any[]
): [R, (newValue: S | ((prevState: S) => S)) => void]

Examples:

  1. Basic usage in a React component:

    // src/stores/counter-store.ts
    import { z } from "zod";
    import { createStore } from "@ibnlanre/portal";
    export const countStore = createStore(z.number(), 0);
    
    // src/components/counter.tsx
    import { countStore } from "../stores/counterStore";
    
    function Counter() {
      const [count, setCount] = countStore.$use();
    
      return (
        <div>
          <p>Count: {count}</p>
          <button onClick={() => setCount(count + 1)}>Increment</button>
          <button onClick={() => setCount((prev) => prev - 1)}>
            Decrement
          </button>
          <button onClick={() => setCount(0)}>Reset</button>
        </div>
      );
    }
    export default Counter;
    
  2. Using a selector with $use: Selectors compute derived values from the store state without modifying the original state. The selector is only re-evaluated when necessary, optimizing performance.

    // In your component:
    // Assume counterStore holds a number.
    const [displayCount, setCount] = counterStore.$use(
      (currentCount) => `Current count is: ${currentCount}`
    );
    // If counterStore holds 0, displayCount is "Current count is: 0".
    // setCount still expects a number to update the original counterStore.
    
    return <p>{displayCount}</p>;
    
  3. Using a selector with dependencies: This is useful when the selector depends on props or other state values. The dependencies can be any value, including primitive values, objects, or arrays. If the dependencies change, the selector will re-run to compute a new value.

    import { useState } from "react";
    import { displayStore } from "./store";
    
    interface DisplayValueProps {
      prefixFromProp: string;
    }
    
    function DisplayValue({ prefixFromProp }: DisplayValueProps) {
      const [displayValue, setDisplayValue] = displayStore.$use(
        (value) => `${prefixFromProp}${value}`,
        [prefixFromProp] // Dependencies array
      );
    
      return (
        <div>
          <p>{displayValue}</p>
          <input
            type="text"
            value={displayValue}
            onChange={(e) => setDisplayValue(e.target.value)}
          />
        </div>
      );
    }
    
  4. Partial updates with objects using $use: When a store (or a part of a store accessed via $use) holds an object, the setState function returned by $use supports partial updates. Provide an object with only the properties you want to change.

    // store.ts
    import { z } from "zod";
    import { createStore } from "@ibnlanre/portal";
    
    export const userStore = createStore(
      z.object({ name: z.string(), age: z.number(), city: z.string() }),
      { name: "Alex", age: 30, city: "Anytown" }
    );
    
    // user-profile.tsx
    import { userStore } from "./store";
    
    function UserProfile() {
      const [user, setUser] = userStore.$use();
    
      const handleAgeIncrease = () => {
        setUser({ age: user.age + 1 }); // Only age is updated; name and city are preserved.
      };
    
      const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setUser({ name: event.target.value }); // Only name is updated.
      };
    
      return (
        <div>
          <input type="text" value={user.name} onChange={handleNameChange} />
          <p>Age: {user.age}</p>
          <p>City: {user.city}</p>
          <button onClick={handleAgeIncrease}>Increase Age</button>
        </div>
      );
    }
    

Define actions: Functions in stores

Use combine() to merge your state and actions into a single object before passing it to createStore. Actions reference the store variable directly—this works because function bodies evaluate lazily, after the variable is assigned.

When defining actions, to update state, use the variable that holds the store instance. For example, store.property.$set(...) inside an action, not this.property.$set(...).

Examples:

  1. Counter with actions:

    import { z } from "zod";
    import { createStore, combine } from "@ibnlanre/portal";
    
    const state = { value: 0 };
    const actions = {
      increment(amount: number = 1) {
        counterStore.value.$set((prev) => prev + amount);
      },
      decrement(amount: number = 1) {
        counterStore.value.$set((prev) => prev - amount);
      },
      reset() {
        counterStore.value.$set(0);
      },
    };
    
    const counterStore = createStore(
      z.object({ value: z.number() }),
      combine(state, actions)
    );
    
    counterStore.increment(5);
    console.log(counterStore.value.$get()); // 5
    
    counterStore.decrement();
    console.log(counterStore.value.$get()); // 4
    
    counterStore.reset();
    console.log(counterStore.value.$get()); // 0
    
  2. Reducer pattern: You can structure actions to follow a reducer pattern if that fits your application’s architecture.

    import { z } from "zod";
    import { createStore, combine } from "@ibnlanre/portal";
    
    type CounterAction =
      | { type: "INCREMENT"; payload: number }
      | { type: "DECREMENT"; payload: number }
      | { type: "RESET" };
    
    const state = { value: 0 };
    const actions = {
      dispatch(action: CounterAction) {
        switch (action.type) {
          case "INCREMENT":
            counterStore.value.$set((prev) => prev + action.payload);
            break;
          case "DECREMENT":
            counterStore.value.$set((prev) => prev - action.payload);
            break;
          case "RESET":
            counterStore.value.$set(0);
            break;
        }
      },
    };
    
    const counterStore = createStore(
      z.object({ value: z.number() }),
      combine(state, actions)
    );
    
    counterStore.dispatch({ type: "INCREMENT", payload: 5 });
    console.log(counterStore.value.$get()); // 5
    
    counterStore.dispatch({ type: "RESET" });
    console.log(counterStore.value.$get()); // 0
    

Actions as hooks

@ibnlanre/portal allows you to define functions within your store that can be used as React custom hooks. Use combine() to add hook functions alongside your state and pass the result to createStore.

To create an action that functions as a hook, simply follow React’s convention:

This pattern leverages React’s own rules for hooks. It doesn’t prevent the function from being recreated on re-renders (which is normal React behavior), but it provides an excellent way to organize and attach reusable hook logic to your store instance.

⚠️ Note: These functions are not automatically memoized. To prevent recreating hook logic on every render, define your store at the module level whenever possible. If you need to create a store inside a React component, use the useMemo hook to ensure the store is created based on stable dependencies.

Example:

Let’s create a store with an action that uses useState and useEffect to automatically reset a message after a delay.

import { z } from "zod";
import { createStore, combine } from "@ibnlanre/portal";
import { useState, useEffect } from "react";

const state = { message: "" };
const actions = {
  setMessage(newMessage: string) {
    notificationStore.message.$set(newMessage);
  },
  useAutoResetMessage(initialMessage: string, delay: number) {
    const [internalMessage, setInternalMessage] = useState(initialMessage);

    useEffect(() => {
      if (internalMessage) {
        const timer = setTimeout(() => {
          setInternalMessage("");
        }, delay);
        return () => clearTimeout(timer);
      }
    }, [internalMessage, delay]);

    useEffect(() => {
      notificationStore.message.$set(internalMessage);
    }, [internalMessage]);

    return [internalMessage, setInternalMessage] as const;
  },
};

export const notificationStore = createStore(
  z.object({ message: z.string() }),
  combine(state, actions)
);

Using the hook action in a component:

import { notificationStore } from "../stores/notification-store";

export function NotificationManager() {
  const [message, setMessage] = notificationStore.useAutoResetMessage(
    "Welcome!",
    3000
  );

  const [globalMessage] = notificationStore.message.$use();

  return (
    <div>
      <p>Current message (from hook state): {message}</p>
      <p>Global message (from store): {globalMessage}</p>
      <button onClick={() => setMessage("Resetting in 3 seconds")}>
        Set Temporary Message
      </button>
    </div>
  );
}

In this example, useAutoResetMessage encapsulates its own state and side effects, just like a custom React hook, while still being able to interact with the global store. This pattern allows you to:

Asynchronous effects: useAsync

The useAsync hook provides a robust solution for handling asynchronous operations within your store actions. It automatically manages loading states, error handling, and data states, making it easy to work with promises and async functions.

Key Features:

Basic Usage:

import { z } from "zod";
import { createStore, combine, useAsync } from "@ibnlanre/portal";

type UserProfile = {
  id: string;
  name: string;
  email: string;
};

const state = {
  users: [],
  profile: null,
};

const actions = {
  useUsers: async () => {
    const { data, loading, error } = useAsync(
      async ({ signal }) => {
        const response = await fetch("/api/users", { signal });

        if (!response.ok) throw new Error("Failed to fetch users");
        return response.json() as UserProfile[];
      } // No dependencies, runs once on mount
    );

    if (data) userStore.users.$set(data);
    return { userLoading: loading, userError: error };
  },
  useProfile: (userId: string) => {
    const { data, loading, error } = useAsync(
      async ({ signal }) => {
        if (!userId) throw new Error("User ID is required");

        const response = await fetch(`/api/users/${userId}`, { signal });

        if (!response.ok) throw new Error("Failed to fetch user");
        return response.json() as UserProfile;
      },
      [userId] // Dependency included in a list
    );

    if (data) userStore.profile.$set(data);
    return { profileLoading: loading, profileError: error };
  },
};

const userStore = createStore(
  z.object({
    users: z.custom<UserProfile[]>(),
    profile: z.custom<UserProfile | null>(),
  }),
  combine(state, actions)
);

Usage in React component:

interface UserProfileComponentProps {
  userId: string;
}

function UserProfileComponent({ userId }: UserProfileComponentProps) {
  const { profileLoading: loading, profileError: error } =
    userStore.useProfile(userId);
  const [profile] = userStore.profile.$use();

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!profile) return <div>No profile found</div>;

  return (
    <div>
      <h1>{profile.name}</h1>
      <p>{profile.email}</p>
    </div>
  );
}

Memoized computations: useSync

The useSync hook provides a useMemo implementation with deep dependency verification. It’s designed to compute and memoize values based on complex dependencies, with automatic re-computation when any part of the dependency tree changes.

Key Features:

Basic Usage:

import { z } from "zod";
import { createStore, combine, useSync } from "@ibnlanre/portal";

const state = {
  fontSize: 16,
  theme: "light",
  language: "en",
};

const actions = {
  useDisplaySettings: () => {
    const [theme] = settingsStore.theme.$use();
    const [fontSize] = settingsStore.fontSize.$use();
    const [language] = settingsStore.language.$use();

    // This will only re-compute when theme, fontSize, or language change
    return useSync(() => {
      return {
        cssVariables: {
          "--theme": theme,
          "--font-size": `${fontSize}px`,
          "--language": language,
        },
        className: `theme-${theme} lang-${language}`,
        styleObject: { fontSize: fontSize, colorScheme: theme },
      };
    }, [theme, fontSize, language]);
  },
};

const settingsStore = createStore(
  z.object({
    fontSize: z.number(),
    theme: z.enum(["light", "dark"]),
    language: z.string(),
  }),
  combine(state, actions)
);

function ThemedComponent() {
  const { className, styledObject, cssVariables } =
    settingsStore.useDisplaySettings();

  return (
    <div className={className} style={styleObject}>
      <p>Theme: {cssVariables["--theme"]}</p>
    </div>
  );
}

Deep dependency tracking: useVersion

The useVersion hook provides deep dependency comparison through deep equality checking, making it ideal for complex state management scenarios. It allows you to track changes in deeply nested objects and arrays, providing both deep equality checking and version tracking for React hooks. It’s the foundational hook used internally by useAsync and useSync, but can also be used directly when you want to build custom hooks with deep dependency checking using native React hooks like useMemo or useEffect.

Key Features:

Basic Usage:

import { z } from "zod";
import { createStore, combine, useVersion } from "@ibnlanre/portal";
import { useEffect } from "react";

const state = {
  theme: "light",
  preferences: {
    language: "en",
    notifications: { email: true, push: false },
  },
};

const actions = {
  useWatchPreferences() {
    const preferences = settingsStore.preferences.$get();
    const version = useVersion(preferences);

    useEffect(() => {
      console.log("Preferences changed:", preferences);
      // Sync preferences to external service
      syncPreferencesToServer(preferences);
    }, [version]);

    return preferences;
  },
};

const settingsStore = createStore(
  z.object({
    theme: z.string(),
    preferences: z.object({
      language: z.string(),
      notifications: z.object({ email: z.boolean(), push: z.boolean() }),
    }),
  }),
  combine(state, actions)
);

The useVersion hook is particularly useful when you want deep dependency tracking for custom hooks, or when native React hooks (useMemo, useEffect, useCallback) need to respond to changes in complex objects or arrays.

Tip: Rather than using useEffect to sync the store state to an external service, consider using $subscribe for more efficient updates. This allows you to subscribe to changes in the store and react accordingly, while also providing a way to unsubscribe when no longer needed.

Context-based stores: createContextStore

The createContextStore function enables efficient global store management through React Context. It provides a powerful pattern for creating provider-based stores that can be initialized with external data and shared across component trees.

Key Features:

Basic Usage:

import { z } from "zod";
import { combine, createStore, createContextStore } from "@ibnlanre/portal";

// Define the context type
type AppContext = {
  userId: string;
  theme: "light" | "dark";
  locale: string;
};

// Create the context scope
const [AppProvider, useAppStore] = createContextStore((context: AppContext) => {
  const initialState = {
    user: {
      id: context.userId,
      preferences: {
        theme: context.theme,
        locale: context.locale,
      },
    },
  };

  const actions = {
    toggleTheme() {
      store.user.preferences.theme.$set((previousTheme: "light" | "dark") => {
        return previousTheme === "light" ? "dark" : "light";
      });
    },
    updateTheme(newTheme: "light" | "dark") {
      store.user.preferences.theme.$set(newTheme);
    },
    updateLocale(newLocale: string) {
      store.user.preferences.locale.$set(newLocale);
    },
  };

  const store = createStore(
    z.object({
      user: z.object({
        id: z.string(),
        preferences: z.object({
          theme: z.enum(["light", "dark"]),
          locale: z.string(),
        }),
      }),
    }),
    combine(initialState, actions)
  );
  return store;
});

Using the context-based store in a React application:

function App() {
  const appContext: AppContext = {
    userId: "user-123",
    theme: "light",
    locale: "en",
  };

  return (
    <AppProvider value={appContext}>
      <UserProfile />
      <Settings />
    </AppProvider>
  );
}

function UserProfile() {
  const store = useAppStore();

  const [userId] = store.user.id.$use();
  const [theme] = store.user.preferences.theme.$use();

  return (
    <div className={`profile theme-${theme}`}>
      <h1>User ID: {userId}</h1>
    </div>
  );
}

function Settings() {
  const store = useAppStore();
  const [theme] = store.user.preferences.theme.$use();

  return (
    <div>
      <button onClick={store.toggleTheme}>
        Toggle Theme (Current: {theme})
      </button>
    </div>
  );
}

Combine stores and actions: combine()

The combine() utility performs a deep merge of multiple objects using a simple spread syntax. It’s useful for unifying your initial state and actions into one cohesive structure before passing it into createStore.

Unlike shallow merging (such as Object.assign or object spread), combine():

Syntax:

combine<Objects extends GenericObject[]>(...objects: Objects): Combine<Objects>

Basic Example:

import { z } from "zod";
import { createStore, combine } from "@ibnlanre/portal";

// Define the initial state
const initialState = {
  isLoggedIn: false,
  profile: {
    email: "alex@example.com",
    name: "Alex",
  },
};

// Define actions separately
const actions = {
  login(email: string) {
    userStore.$set({
      profile: { email },
      isLoggedIn: true,
    });
  },
  logout() {
    userStore.$set({
      isLoggedIn: false,
      profile: { email: "", name: "" },
    });
  },
  updateName(newName: string) {
    userStore.profile.name.$set(newName);
  },
};

// Combine initial state and actions, pass schema + combined object to createStore
export const userStore = createStore(
  z.object({
    isLoggedIn: z.boolean(),
    profile: z.object({ email: z.string(), name: z.string() }),
  }),
  combine(initialState, actions)
);

Multiple Objects Example:

import { createStore, combine } from "@ibnlanre/portal";

// Base configuration
const baseConfig = {
  api: {
    baseUrl: "https://api.example.com",
    timeout: 5000,
  },
  ui: {
    theme: "light",
    language: "en",
  },
};

// Environment-specific overrides
const developmentConfig = {
  api: {
    baseUrl: "https://dev-api.example.com",
    debug: true,
  },
  ui: {
    showDebugInfo: true,
  },
};

// User preferences
const userPreferences = {
  ui: {
    theme: "dark",
    fontSize: 16,
  },
  notifications: {
    email: true,
    push: false,
  },
};

// Combine all sources - later objects override earlier ones
const appConfig = combine(baseConfig, developmentConfig, userPreferences);

// Result will be:
// {
//   api: {
//     baseUrl: "https://dev-api.example.com", // from developmentConfig
//     timeout: 5000,                          // from baseConfig
//     debug: true                             // from developmentConfig
//   },
//   ui: {
//     theme: "dark",                          // from userPreferences
//     language: "en",                         // from baseConfig
//     showDebugInfo: true,                    // from developmentConfig
//     fontSize: 16                            // from userPreferences
//   },
//   notifications: {                          // from userPreferences
//     email: true,
//     push: false
//   }
// }

const configStore = createStore(z.custom<typeof appConfig>(), appConfig);

Edge Cases:

// Empty call returns empty object
const empty = combine(); // {}

// Single object returns as-is (no cloning)
const single = combine(baseConfig); // baseConfig

// Works with spread arrays (types inferred as union of array elements)
const configs = [config1, config2, config3];
const merged = combine(...configs);

Initialize state asynchronously

You can initialize a store with state fetched asynchronously by passing an async function (that returns a Promise) to createStore. The store will initially be empty (or hold the unresolved Promise object itself, depending on internal handling) until the Promise resolves.

Important Considerations:

Example:

import { z } from "zod";
import { createStore } from "@ibnlanre/portal";

interface UserData {
  id: number;
  name: string;
  email: string;
}

async function fetchInitialData(): Promise<UserData> {
  // Simulate API call
  return new Promise((resolve) =>
    setTimeout(
      () => resolve({ id: 1, name: "Lyn", email: "lyn@example.com" }),
      500
    )
  );
}

const userStore = await createStore(z.custom<UserData>(), fetchInitialData());
// At this point, the promise has resolved, and the store is initialized.
const userData = userStore.$get();
console.log(userData); // { id: 1, name: "Lyn", email: "lyn@example.com" }

// userData is a single object. userStore.id does not exist as a sub-store.
// To update, you'd set the whole object:
userStore.$set({ id: 2, name: "Alex", email: "alex@example.com" });

If you need a nested store structure from asynchronously loaded data, initialize the store with a placeholder structure (or null) and then update it using $set once the data is fetched. This allows the composite store structure to be established correctly.

import { z } from "zod";
import { createStore } from "@ibnlanre/portal";

interface AppData {
  user: { name: string; role: string } | null;
  settings: { theme: string } | null;
  loading: boolean;
}

const appDataStore = createStore(
  z.object({
    user: z.custom<{ name: string; role: string } | null>(),
    settings: z.custom<{ theme: string } | null>(),
    loading: z.boolean(),
  }),
  { user: null, settings: null, loading: true }
);

async function loadAppData() {
  try {
    // const fetchedData = await fetchActualDataFromAPI();
    const fetchedData = {
      // Example fetched data
      user: { name: "Sam", role: "admin" },
      settings: { theme: "dark" },
    };
    appDataStore.$set({ ...fetchedData, loading: false });

    // Now appDataStore.user.name.$get() would work.
    console.log(appDataStore.user.name.$get()); // "Sam"
  } catch (error) {
    console.error("Failed to load app data:", error);
    appDataStore.$set({ user: null, settings: null, loading: false }); // Handle error state
  }
}

loadAppData();

Handle circular references

@ibnlanre/portal handles objects with circular references safely during store creation and operations. This is particularly useful for complex data structures, such as graphs or when working with certain browser objects (after normalization).

Example:

import { z } from "zod";
import { createStore } from "@ibnlanre/portal";

// Define a type for clarity
interface Node {
  name: string;
  connections: Node[];
  metadata?: { type: string };
}

const nodeA: Node = {
  name: "A",
  connections: [],
  metadata: { type: "root" },
};
const nodeB: Node = {
  name: "B",
  connections: [],
  metadata: { type: "leaf" },
};

nodeA.connections.push(nodeB); // nodeA points to nodeB
nodeB.connections.push(nodeA); // nodeB points back to nodeA (circular reference)

const graphStore = createStore(
  z.object({
    nodes: z.custom<Node[]>(),
    selectedNode: z.object({
      name: z.string(),
      connections: z.custom<Node[]>(),
      metadata: z.object({ type: z.string() }).optional(),
    }),
  }),
  {
    nodes: [nodeA, nodeB],
    selectedNode: nodeA,
  }
);

// Accessing data:
// 1. For 'selectedNode' (a direct object property, so it and its properties are stores)
console.log(graphStore.selectedNode.name.$get()); // "A"

if (graphStore.selectedNode.metadata) {
  // Check if metadata exists
  console.log(graphStore.selectedNode.metadata.type.$get()); // "root"
}

// 2. For 'nodes' (an array property; elements are not individual stores)
const currentNodes = graphStore.nodes.$get(); // Get the array value

// Access properties of objects within the 'currentNodes' array
console.log(currentNodes[0].name); // "A" (Accessing nodeA.name directly)
console.log(currentNodes[0].connections[0].name); // "B" (Accessing nodeA.connections[0].name which is nodeB.name)

// Demonstrating the circular reference is preserved:
console.log(currentNodes[0].connections[0].connections[0].name); // "A" (nodeA -> nodeB -> nodeA)

// Updates also work correctly:
// Update via 'selectedNode' store
graphStore.selectedNode.name.$set("Node Alpha");
console.log(graphStore.selectedNode.name.$get()); // "Node Alpha"

// The original nodeA object (referenced by selectedNode and within the nodes array) is updated
console.log(nodeA.name); // "Node Alpha"

// Verify in the array retrieved from the store
const updatedNodes = graphStore.nodes.$get();
console.log(updatedNodes[0].name); // "Node Alpha"

// If you were to update an element within the 'nodes' array, you'd do it like this:
graphStore.nodes.$set((prevNodes) => {
  const newNodes = [...prevNodes];

  // Example: Change name of the second node (nodeB)
  if (newNodes[1]) {
    newNodes[1] = { ...newNodes[1], name: "Node Beta" };
  }

  return newNodes;
});

// Assuming nodeB was correctly updated in the array:
const finalNodes = graphStore.nodes.$get();

if (finalNodes[1]) {
  console.log(finalNodes[1].name); // Should be "Node Beta"
}

// And the original nodeB object is also updated if its reference was maintained
console.log(nodeB.name); // "Node Beta"

Warning: Circular references are supported, but be mindful of performance with very large or deeply nested graphs.

Handle arrays in stores

When your store’s state includes arrays, @ibnlanre/portal treats them in a specific way:

This behavior ensures that arrays are managed predictably as collections, while direct object properties of a store are augmented for more granular control. If you require each item in a collection to have full store capabilities, consider structuring your state as an object mapping IDs to individual stores, rather than an array of items. For example:

const itemStores = createStore(
  z.object({
    item_1: z.object({ name: z.string(), stock: z.number() }),
    item_2: z.object({ name: z.string(), stock: z.number() }),
  }),
  {
    item_1: { name: "Item A", stock: 10 },
    item_2: { name: "Item B", stock: 5 },
  }
);
// Now itemStores.item_1 is a store, itemStores.item_1.name is a store, etc.

Infer state types: InferType

The InferType utility type allows you to extract TypeScript types from your Portal stores. This is especially useful when you need to work with the underlying state type in other parts of your application, such as API calls, form validation, or when passing state to other components.

Syntax:

InferType<Store, Path?>;

Parameters:

Returns: The TypeScript type of the store’s state, or the type at the specified path

Examples:

  1. Infer the complete state type:

    import { createStore, InferType } from "@ibnlanre/portal";
    
    const userStore = createStore(
      z.object({
        age: z.number(),
        name: z.string(),
        preferences: z.object({ notifications: z.boolean(), theme: z.string() }),
      }),
      {
        age: 30,
        name: "Alice",
        preferences: { notifications: true, theme: "dark" },
      }
    );
    
    // Infer the complete state type
    type UserState = InferType<typeof userStore>;
    
    function saveUserToAPI(user: UserState) {
      return fetch("/api/users", {
        body: JSON.stringify(user),
        method: "POST",
      });
    }
    
    // Get the current state with correct typing
    const currentUser = userStore.$get(); // Type is automatically UserState
    saveUserToAPI(currentUser);
    
  2. Infer specific nested types:

    import { createStore, InferType } from "@ibnlanre/portal";
    
    const appStore = createStore(
      z.object({
        data: z.object({ comments: z.array(z.any()), posts: z.array(z.any()) }),
        user: z.object({
          profile: z.object({ email: z.string(), name: z.string() }),
          settings: z.object({ language: z.string(), theme: z.string() }),
        }),
      }),
      {
        data: { comments: [], posts: [] },
        user: {
          profile: { email: "bob@example.com", name: "Bob" },
          settings: { language: "en", theme: "light" },
        },
      }
    );
    
    type AppData = InferType<typeof appStore, "data">;
    // UserProfile is: { name: string; email: string; }
    
    // Extract specific nested types
    type UserProfile = InferType<typeof appStore, "user.profile">;
    // UserSettings is: { theme: string; language: string; }
    
    type UserSettings = InferType<typeof appStore, "user.settings">;
    // AppData is: { posts: any[]; comments: any[]; }
    
    // Use inferred types for type-safe operations
    function updateProfile(newProfile: Partial<UserProfile>) {
      appStore.user.profile.$set((current) => ({ ...current, ...newProfile }));
    }
    
    function updateSettings(settings: UserSettings) {
      appStore.user.settings.$set(settings);
    }
    
  3. Use with primitive stores:

    import { createStore, InferType } from "@ibnlanre/portal";
    
    const countStore = createStore(z.number(), 0);
    const nameStore = createStore(z.string(), "Hello");
    const itemsStore = createStore(z.array(z.string()), []);
    
    type CountType = InferType<typeof countStore>; // number
    type ItemsType = InferType<typeof itemsStore>; // string[]
    type NameType = InferType<typeof nameStore>; // string
    
    // Use inferred types in function parameters
    function processCount(value: CountType) {
      console.log(`Processing count: ${value}`);
    }
    
    function processItems(items: ItemsType) {
      return items.map((item) => item.toUpperCase());
    }
    
  4. Integration with forms and validation:

    import { createStore, InferType } from "@ibnlanre/portal";
    
    const formStore = createStore(
      z.object({
        email: z.string(),
        profile: z.object({
          bio: z.string(),
          firstName: z.string(),
          lastName: z.string(),
        }),
        username: z.string(),
      }),
      {
        email: "",
        profile: { bio: "", firstName: "", lastName: "" },
        username: "",
      }
    );
    
    type FormData = InferType<typeof formStore>;
    type ProfileData = InferType<typeof formStore, "profile">;
    
    async function submitForm(formData: FormData) {
      const response = await fetch("/api/register", {
        body: JSON.stringify(formData),
        headers: { "Content-Type": "application/json" },
        method: "POST",
      });
      return response.json();
    }
    
    function validateForm(data: FormData): boolean {
      return (
        data.username.length > 0 &&
        data.email.includes("@") &&
        data.profile.firstName.length > 0
      );
    }
    
    const currentFormData = formStore.$get();
    if (validateForm(currentFormData)) {
      await submitForm(currentFormData);
    }
    

The InferType utility ensures type safety when working with store data outside of the reactive context, making it easier to integrate Portal stores with other parts of your TypeScript application.

TypeScript Tips

@ibnlanre/portal is designed with TypeScript-first development in mind. Here are some best practices to get the most out of the type system:

Type Inference

Store types are automatically inferred from the initial state, so you often don’t need explicit type annotations:

// Type is inferred as: PrimitiveStore<number>
const countStore = createStore(z.number(), 0);

// Type is inferred as: CompositeStore<{ name: string; age: number }>
const userStore = createStore(z.object({ name: z.string(), age: z.number() }), {
  name: "Alex",
  age: 30,
});

Explicit Type Parameters

For complex scenarios or when initializing asynchronously with placeholder values, use explicit type parameters:

interface User {
  id: string;
  name: string;
  email: string;
}

// Initialize with null but specify the full type
const userStore = createStore(z.custom<User | null>(), null);

// Now TypeScript knows userStore will eventually hold a User
const [user] = userStore.$use((value) => {
  if (value) return value.name;
  return "Guest";
});

Deep Partial Updates

Take advantage of deep partial updates when working with nested objects. TypeScript will verify that your update shape is compatible:

const appStore = createStore(
  z.object({
    user: z.object({ name: z.string(), age: z.number(), role: z.string() }),
    config: z.object({ theme: z.string(), language: z.string() }),
  }),
  {
    user: { name: "Alice", age: 30, role: "admin" },
    config: { theme: "light", language: "en" },
  }
);

// ✅ Valid: partial update of nested property
appStore.user.$set({ name: "Bob" });

// ✅ Valid: partial update at any depth
appStore.$set({
  user: { role: "moderator" },
  config: { theme: "dark" },
});

Type-Safe Selectors

Use selectors in $use and $get to derive typed values:

const userStore = createStore(
  z.object({ name: z.string(), email: z.string() }),
  { name: "Alex", email: "alex@example.com" }
);

// Selector return type is inferred as string
const [displayName] = userStore.$use(
  (user) => `User: ${user.name} (${user.email})`
);

// No need to manually type the selector result
const [stats] = userStore.$use((user) => ({
  letterCount: user.name.length,
}));

Type-Safe Actions

Actions in stores inherit the type context and can provide full autocomplete:

import { z } from "zod";
import { createStore, combine } from "@ibnlanre/portal";

const state = { value: 0 };
const actions = {
  increment(amount: number = 1) {
    counterStore.value.$set((prev) => prev + amount);
  },
  multiply(factor: number) {
    counterStore.value.$set((prev) => prev * factor);
  },
};

const counterStore = createStore(
  z.object({ value: z.number() }),
  combine(state, actions)
);

// ✅ Full autocomplete for actions
counterStore.increment(5);
counterStore.multiply(2);

Combining Multiple Stores

When combining stores, ensure compatibility with the combine utility:

import { combine, createStore } from "@ibnlanre/portal";

const state = { count: 0, message: "Hello" };
const actions = {
  increment() {
    store.count.$set((v: number) => v + 1);
  },
};

// combine ensures proper type merging
const store = createStore(
  z.object({ count: z.number(), message: z.string() }),
  combine(state, actions)
);

// ✅ Access both state and actions with full type safety
store.count.$get(); // ✅ number
store.increment(); // ✅ function

Persist state

@ibnlanre/portal allows you to persist store state across sessions using storage adapters. These adapters provide getState and setState functions that you integrate with your store.

Web Storage adapters

Use createLocalStorageAdapter or createSessionStorageAdapter to persist in the browser’s Local Storage or Session Storage.

Syntax:

createLocalStorageAdapter<State>(key: string, options?: StorageAdapterOptions<State>)
createSessionStorageAdapter<State>(key: string, options?: StorageAdapterOptions<State>)

Parameters:

Return Value: Both adapters return a tuple: [getStateFunction, setStateFunction].

createLocalStorageAdapter

Persists state in localStorage. Data remains until explicitly cleared or removed by the user/browser.

Example:

import { createStore, createLocalStorageAdapter } from "@ibnlanre/portal";

const localStorageAdapter = createLocalStorageAdapter<number>("app-counter", {
  // Example with custom serialization (e.g., simple obfuscation)
  // stringify: (state) => btoa(JSON.stringify(state)),
  // parse: (storedString) => JSON.parse(atob(storedString)),
});

const [getStoredCounter, setStoredCounter] = localStorageAdapter;

// Load persisted state or use a default if nothing is stored
const initialCounterState = getStoredCounter(0); // Default to 0 if null
const persistentCounterStore = createStore(z.number(), initialCounterState);

// Subscribe to store changes to save them to Local Storage
persistentCounterStore.$subscribe((newState) => {
  setStoredCounter(newState);
}, false); // `false` prevents saving immediately on setup, only on actual changes

// Example usage:
persistentCounterStore.$set(10); // State is now 10 and saved to Local Storage
persistentCounterStore.$set((prev) => prev + 5); // State is 15 and saved

createSessionStorageAdapter

Persists state in sessionStorage. Data remains for the duration of the page session (until the browser tab is closed).

Example:

import { createStore, createSessionStorageAdapter } from "@ibnlanre/portal";

const [getStoredSessionData, setStoredSessionData] =
  createSessionStorageAdapter<{
    guestId: string | null;
    lastPage: string;
  }>("userSessionData");

const initialSessionData = getStoredSessionData({
  guestId: null,
  lastPage: "/",
});
const sessionDataStore = createStore(
  z.object({
    guestId: z.string().nullable(),
    lastPage: z.string(),
  }),
  initialSessionData
);

sessionDataStore.$subscribe(setStoredSessionData, false);

// Example:
sessionDataStore.$set({ guestId: "guest-123", lastPage: "/products" });
// This data will be cleared when the tab is closed.

Use createCookieStorageAdapter for persisting state in browser cookies.

Syntax:

createCookieStorageAdapter<State>(key: string, options?: CookieStorageAdapterOptions<State>)

Parameters:

Return Value: [getCookieStateFunction, setCookieStateFunction]

Example:

import { createStore, createCookieStorageAdapter } from "@ibnlanre/portal";

const cookieAdapter = createCookieStorageAdapter<{
  theme: "light" | "dark";
  notifications: boolean;
}>("app-preferences", {
  secret: "your-very-strong-secret-key-for-signing", // Recommended for security
  path: "/",
  secure: true,
  sameSite: "lax",
  maxAge: 3600 * 24 * 30, // 30 days
});

const [getCookiePreferences, setCookiePreferences] = cookieAdapter;

const initialPrefs = getCookiePreferences({
  theme: "light",
  notifications: true,
});
const prefsStore = createStore(
  z.object({ theme: z.string(), notifications: z.boolean() }),
  initialPrefs
);

prefsStore.$subscribe((newPrefs) => {
  setCookiePrefs(newPrefs);

  // Example: Update maxAge on a specific change
  if (newPrefs.notifications === false) {
    setCookiePrefs(newPrefs, { maxAge: 3600 * 24 }); // Shorter expiry if notifications off
  }
}, false);

prefsStore.$set({ theme: "dark" }); // State saved to a signed cookie

Signed cookies: If you provide a secret, cookies are automatically signed before being set and verified when retrieved. This helps protect against client-side tampering.

Browser Storage adapter

For custom storage mechanisms (e.g., IndexedDB, a remote API, chrome.storage), use createBrowserStorageAdapter.

Syntax:

createBrowserStorageAdapter<State, StoredState>(
  key: string,
  options: BrowserStorageAdapterOptions<State, StoredState>
): [
  getStorageState: GetBrowserStorage<State>,
  setStorageState: SetBrowserStorage<State>,
]

Parameters:

Return Value: [getStorageState, setStorageState]

Example (using a simple in-memory object as custom storage):

import { createStore, createBrowserStorageAdapter } from "@ibnlanre/portal";

const customStorage = {
  data: {} as Record<string, string>,
  getItem(key: string) {
    return customDataStore.data[key];
  },
  removeItem(key: string) {
    delete customDataStore.data[key];
  },
  setItem(key: string, value: string) {
    customDataStore.data[key] = value;
  },
};

const [getCustomState, setCustomState] = createBrowserStorageAdapter<{
  lastSync: null | string;
}>("custom-key", customStorage);

const initialCustomData = getCustomState({ lastSync: null });
const customDataStore = createStore(
  z.object({ lastSync: z.string().nullable() }),
  initialCustomData
);

customDataStore.$subscribe(setCustomState, false);
customDataStore.$set({ lastSync: new Date().toISOString() });

Async Browser Storage adapter

The createAsyncBrowserStorageAdapter is a more flexible version of createBrowserStorageAdapter. It allows for asynchronous transformations of your state, which is useful when you need to perform operations like encryption, compression, or other async tasks before storing or retrieving the state.

The adapter provides two functions: one for getting the state from storage and another for setting the state to storage. Both functions can handle asynchronous operations, allowing you to work with data that requires processing before being used in your application.

Syntax:

createAsyncBrowserStorageAdapter<State, StoredState = State>(
  key: string,
  options: AsyncBrowserStorageAdapterOptions<State, StoredState>
): [
  getStorageState: AsyncGetBrowserStorage<State>,
  setStorageState: AsyncSetBrowserStorage<State>,
]

Parameters:

Return Value: [getStorageState, setStorageState]

Example:

Let’s create an adapter that simulates async encryption and decryption.

import { createAsyncBrowserStorageAdapter } from "@ibnlanre/portal";

const [getEncryptedState, setEncryptedState] = createAsyncBrowserStorageAdapter<
  { sensitive: string },
  string
>("encrypted", {
  getItem(key) {
    return localStorage.getItem(key);
  },
  removeItem(key) {
    return localStorage.removeItem(key);
  },
  setItem(key, value) {
    return localStorage.setItem(key, value);
  },
  beforeStorage(data) {
    return btoa(JSON.stringify(data)); // Encrypt before storing
  },
  beforeUsage(data) {
    return JSON.parse(atob(data)); // Decrypt when retrieving
  },
});

// Now, you can use these functions to persist a store
const result = await getEncryptedState({ sensitive: "data" });

// When the store is initialized, the data will be decrypted.
const store = createStore(result);

// When you set the state, it will be encrypted before being stored.
store.$subscribe(setEncryptedState, false);

Cross-tab sync: syncStorage()

The syncStorage() utility listens for cross-tab storage events and syncs the value into the store when the matching key changes in another tab. SSR-safe — no-ops when window is not available.

import { z } from "zod";
import {
  createStore,
  createLocalStorageAdapter,
  syncStorage,
} from "@ibnlanre/portal";

const [getConsent, setConsent] = createLocalStorageAdapter<
  "granted" | "denied" | null
>("consent");

const store = createStore(
  z.union([z.literal("granted"), z.literal("denied"), z.null()]),
  getConsent(null)
);

// Sync from the adapter on every store change
store.$subscribe(setConsent, false);

// Sync from other tabs into the store
syncStorage(store, "consent", getConsent);

Beyond the createCookieStorageAdapter, @ibnlanre/portal also exposes a cookieStorage module. This module provides a collection of utility functions for direct, granular manipulation of browser cookies. You might use these functions if you need to interact with cookies outside the context of a store or require more fine-grained control than the adapter offers.

These utilities are particularly helpful for tasks like signing/unsigning cookie values for security, directly reading or writing specific cookies, or managing cookie properties with precision.

Access the module:

To use these utilities, import cookieStorage:

import { cookieStorage } from "@ibnlanre/portal";

The cookieStorage object provides the following functions and properties:

sign()

Signs a string value using a secret key. This is useful for creating tamper-proof cookie values.

unsign()

Verifies and unsigns a previously signed string using the corresponding secret key.

getItem()

Retrieves the value of a cookie by its name (key).

setItem()

Sets or updates a cookie’s value. You can also provide cookie options.

removeItem()

Removes a cookie by its name.

clear()

Attempts to clear all cookies accessible to the current document’s path and domain by setting their expiration date to the past. Note that this might not remove cookies set with specific path or domain attributes unless those are also iterated and cleared individually.

createKey()

Constructs a standardized cookie name string based on a set of provided options. This helps maintain consistent naming conventions for cookies across your application.

key()

Retrieves the name (key) of a cookie at a specific index in the document’s cookie string. The order of cookies can be browser-dependent.

length (Property)

Retrieves the total number of cookies accessible to the current document.

Optimize performance

@ibnlanre/portal is designed for performance, but here are some tips for optimal usage in complex applications:

Understand limitations

While @ibnlanre/portal is versatile, it’s important to be aware of its limitations:

const store = createStore(
  z.object({
    name: z.string(),
    lastLogin: z.custom<Date>(),
    preferences: z.object({ theme: z.string(), notifications: z.boolean() }),
  }),
  {
    name: "Alice",
    lastLogin: new Date(), // Date objects need custom serialization
    preferences: { theme: "dark", notifications: true },
  }
);

const [getState, setState] = createLocalStorageAdapter("user-store", {
  stringify: (state) =>
    JSON.stringify({
      ...state,
      lastLogin: state.lastLogin.toISOString(), // Convert Date to string
    }),
  parse: (storedString) => {
    const parsed = JSON.parse(storedString);
    return {
      ...parsed,
      lastLogin: new Date(parsed.lastLogin), // Convert string back to Date
    };
  },
});
// ❌ Direct mutation - won't trigger reactivity
const user = userStore.$get();
user.name = "New Name"; // No reactivity triggered

// ✅ Correct way - triggers reactivity
userStore.$set((prev) => ({ ...prev, name: "New Name" }));

Follow best practices

To make the most of @ibnlanre/portal, consider these best practices:

Contribute to the project

Contributions are welcome! We appreciate your help in making @ibnlanre/portal better. You can contribute by:

Please read our CONTRIBUTING.md file for detailed guidelines on how to contribute, including coding standards, testing requirements, and the pull request process.

Get help and support

If you need help using @ibnlanre/portal, here are the resources available to you:

  1. Documentation:
  2. Community Support:
  3. Report Issues: If you can’t find a solution, open a new issue on GitHub with:
    • Version of @ibnlanre/portal
    • Steps to reproduce the issue
    • Relevant code snippets
    • Error messages (if any)
    • Environment details (browser, OS, etc.)

License

This project is licensed under the BSD-3-Clause License. Copyright (c) 2025, ibnlanre.