portal

@ibnlanre/portal

npm version Build Status License: BSD-3-Clause

@ibnlanre/portal is a TypeScript state management library designed to help you manage complex application state with an intuitive API. It focuses on developer experience, robust type safety, and comprehensive features. This document is your complete guide to understanding, installing, and effectively using the library.

Table of contents

Features

@ibnlanre/portal offers a robust set of features to streamline 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 -->

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

@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).

    import { createStore } from "@ibnlanre/portal";
    
    const countStore = createStore(0); // A primitive store for a number
    const nameStore = createStore("Alex"); // A primitive store for a string
    
  2. Composite Store: Manages an object and enables 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.

    import { createStore } from "@ibnlanre/portal";
    
    const userSettingsStore = createStore({
      profile: {
        name: "Alex",
        age: 30,
      },
      preferences: {
        theme: "dark",
        notificationsEnabled: true,
      },
    });
    // userSettingsStore is a composite store.
    // userSettingsStore.profile is also a composite store.
    // userSettingsStore.profile.name is a primitive store.
    

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.

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. Store Initialization: When you call createStore(), you provide the initial state. This is the main configuration for a store’s structure and default values.
  2. Persistence Adapters: If you use state persistence, you configure adapters with options like storage keys and serialization functions.

For example, to configure a Local Storage adapter:

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

const [getPersistentState, setPersistentState] = createLocalStorageAdapter(
  "myAppUniqueStorageKey", // Required: A unique key for this store in Local Storage
  {
    // Optional: Custom serialization/deserialization
    stringify: (state) => JSON.stringify(state),
    parse: (storedString) => JSON.parse(storedString),
  }
);

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.

Syntax:

createStore<S>(initialState: S | Promise<S>): Store<S>

Examples:

  1. Creating a primitive store:

    import { createStore } from "@ibnlanre/portal";
    
    const countStore = createStore(0);
    console.log(countStore.$get()); // Output: 0
    
    const messageStore = createStore("Hello, world!");
    console.log(messageStore.$get()); // Output: "Hello, world!"
    
  2. Creating a composite store:

    import { createStore } from "@ibnlanre/portal";
    
    const userStore = createStore({
      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 { createStore } from "@ibnlanre/portal";
    
    async function fetchUserData(): Promise<{ id: number; name: string }> {
      return new Promise((resolve) => {
        setTimeout(() => resolve({ id: 1, name: "Fetched User" }), 1000);
      });
    }
    
    const userProfileStore = await createStore(fetchUserData());
    // The store is now initialized as a primitive store with the fetched data.
    // Note: userProfileStore holds { id: 1, name: "Fetched User" } as a single value.
    // It's not created as a composite store despite being an object.
    
    console.log(userProfileStore.$get()); // Output: { id: 1, name: "Fetched User" }
    

Use store instance methods

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

$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(10);
    const currentCount = countStore.$get(); // 10
    
    const userStore = createStore({ 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(10);
    const doubledCount = countStore.$get((count) => count * 2); // 20
    console.log(countStore.$get()); // 10 (original state is unchanged)
    
    const userStore = createStore({ 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(0);
    countStore.$set(5);
    console.log(countStore.$get()); // 5
    
  2. Updating using a function (Primitive Store):

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

    const settingsStore = createStore({
      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({
      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({ 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]

$act()

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:

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

Examples:

  1. Basic subscription:

    const nameStore = createStore("Alex");
    
    const unsubscribe = nameStore.$act((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("idle");
    
    const unsubscribeNonImmediate = statusStore.$act((newStatus) => {
      console.log(`Status updated to: ${newStatus}`);
    }, false); // `false` prevents immediate call
    
    statusStore.$set("active"); // Logs: "Status updated to: active"
    unsubscribeNonImmediate();
    
  3. Subscribing to a composite store:

    const settingsStore = createStore({ theme: "light", volume: 70 });
    const unsubscribeSettings = settingsStore.$act((newSettings) => {
      console.log("Settings updated:", newSettings);
    });
    // Immediately logs: Settings updated: { theme: "light", volume: 70 }
    
    settingsStore.theme.$set("dark");
    // Logs: Settings updated: { theme: "dark", volume: 70 }
    unsubscribeSettings();
    

$key()

(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, $act, $use, $key) directly.

Syntax:

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

Examples:

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

// Access nested stores using $key
const themeStore = appStore.$key("user.preferences.theme");
console.log(themeStore.$get()); // "dark"

themeStore.$set("light");
console.log(appStore.user.preferences.theme.$get()); // "light" (state is synced)

// $key can be used on intermediate stores as well
const preferencesStore = appStore.user.$key("preferences");
const languageStore = preferencesStore.$key("language"); // Equivalent to appStore.$key("user.preferences.language")
console.log(languageStore.$get()); // "en"

// Using methods on the store returned by $key
const unsubscribeTheme = appStore
  .$key("user.preferences.theme")
  .$act((newTheme) => {
    console.log("Theme via $key:", newTheme);
  });
appStore.user.preferences.theme.$set("blue"); // Triggers the subscription
unsubscribeTheme();

$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/counterStore.ts
    import { createStore } from "@ibnlanre/portal";
    export const counterStore = createStore(0);
    
    // src/components/Counter.tsx
    import React from "react";
    import { counterStore } from "../stores/counterStore";
    
    function Counter() {
      const [count, setCount] = counterStore.$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:

    // 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:

    import React, { useState } from "react";
    import { someStore } from "../stores/someStore"; // Assume someStore holds a string
    
    function DisplayValue({ prefixFromProp }: { prefixFromProp: string }) {
      const [displayValue, setValueInStore] = someStore.$use(
        (storeValue) => `${prefixFromProp}${storeValue}`,
        [prefixFromProp] // Selector re-runs if prefixFromProp changes
      );
    
      return (
        <div>
          <p>{displayValue}</p>
          <input
            type="text"
            value={someStore.$get()} // For controlled input, get raw value
            onChange={(e) => setValueInStore(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 { createStore } from "@ibnlanre/portal";
    export const userStore = createStore({
      name: "Alex",
      age: 30,
      city: "Anytown",
    });
    
    // Component.tsx
    import React from "react";
    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

You can include functions within the initial state object of a composite store. These functions become methods on the store, allowing you to co-locate state logic (actions) with the state itself. This is useful for encapsulating complex state transitions.

When defining actions, to update state, you must use the variable that holds the store instance. For example, if your store is const myStore = createStore(...), you would use myStore.someProperty.$set(...) inside an action, not this.someProperty.$set(...).

Examples:

  1. Counter with actions:

    import { createStore } from "@ibnlanre/portal";
    
    const counterStore = createStore({
      value: 0,
      increment(amount: number = 1) {
        // To update 'value', use 'counterStore.value'
        counterStore.value.$set((prev) => prev + amount);
      },
      decrement(amount: number = 1) {
        counterStore.value.$set((prev) => prev - amount);
      },
      reset() {
        counterStore.value.$set(0);
      },
    });
    
    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 { createStore } from "@ibnlanre/portal";
    
    type CounterAction =
      | { type: "INCREMENT"; payload: number }
      | { type: "DECREMENT"; payload: number }
      | { type: "RESET" };
    
    const dispatchingCounterStore = createStore({
      value: 0,
      dispatch(action: CounterAction) {
        switch (action.type) {
          case "INCREMENT":
            // Use 'dispatchingCounterStore.value' to access $set
            dispatchingCounterStore.value.$set((prev) => prev + action.payload);
            break;
          case "DECREMENT":
            dispatchingCounterStore.value.$set((prev) => prev - action.payload);
            break;
          case "RESET":
            dispatchingCounterStore.value.$set(0);
            break;
        }
      },
    });
    
    dispatchingCounterStore.dispatch({ type: "INCREMENT", payload: 5 });
    console.log(dispatchingCounterStore.value.$get()); // 5
    
    dispatchingCounterStore.dispatch({ type: "RESET" });
    console.log(dispatchingCounterStore.value.$get()); // 0
    

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 { 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 asyncUserStore = await createStore(fetchInitialData());
// At this point, the promise has resolved, and the store is initialized.
const userData = asyncUserStore.$get();
console.log(userData); // { id: 1, name: "Lyn", email: "lyn@example.com" }

// userData is a single object. asyncUserStore.id does not exist as a sub-store.
// To update, you'd set the whole object:
asyncUserStore.$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 { createStore } from "@ibnlanre/portal";

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

const appDataStore = createStore<AppData>({
  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 { 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({
  nodes: [nodeA, nodeB], // 'nodes' is an array of Node objects
  selectedNode: nodeA, // 'selectedNode' is a Node object
});

// 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"

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({
  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.

Normalize objects: normalizeObject()

The normalizeObject() function converts interface-typed objects (like window, DOM elements, or complex API responses that might not be plain JavaScript objects) into dictionary types (Record<PropertyKey, unknown>) compatible with composite stores. This is important when an object’s structure includes methods, symbols, or other non-serializable parts that shouldn’t be part of the reactive state, or when TypeScript’s type system for interfaces doesn’t align with the expected structure for a composite store.

normalizeObject helps by:

Important: The normalized object will only contain properties with string or number keys. Symbol keys are excluded during normalization.

Syntax:

normalizeObject<T extends object>(obj: T): Record<PropertyKey, unknown> // Simplified, actual return might be more specific

Examples:

  1. Normalizing the browser’s window object:

    import { createStore, normalizeObject } from "@ibnlanre/portal";
    
    // The 'window' object is complex and has an interface type.
    // Normalizing it makes it suitable for a composite store.
    const normalizedWindow = normalizeObject(window);
    const browserInfoStore = createStore(normalizedWindow);
    
    // Now you can access properties like browserInfoStore.navigator.userAgent.$get()
    // Note: Functions on window (like alert) would typically be excluded by normalization.
    console.log(browserInfoStore.location.href.$get());
    
    // This might work if 'document' is data-like
    console.log(browserInfoStore.document.title.$get());
    
  2. Normalizing a custom interface with methods and symbols:

    import { createStore, normalizeObject } from "@ibnlanre/portal";
    
    interface UserProfileAPI {
      id: number;
      getFullName(): string; // A method
      lastLogin: Date;
      internalConfig: symbol; // A symbol
      data: { value: string };
    }
    
    const apiResponse: UserProfileAPI = {
      id: 123,
      getFullName: () => "Alex Doe",
      lastLogin: new Date(),
      internalConfig: Symbol("config"),
      data: { value: "test" },
    };
    
    const normalizedUserProfile = normalizeObject(apiResponse);
    /*
      normalizedUserProfile will be:
      {
        id: 123,
        lastLogin: // Date object (preserved as it's data-like)
        data: { value: "test" }
      }
      // The getFullName method and internalConfig symbol are removed by normalization,
      // as functions and symbol keys are filtered out.
    */
    
    const userProfileStore = createStore(normalizedUserProfile);
    console.log(userProfileStore.id.$get()); // 123
    console.log(userProfileStore.data.value.$get()); // "test"
    
    // userProfileStore.getFullName is undefined (method was stripped)
    // userProfileStore.internalConfig is undefined (symbol key was excluded)
    

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 state in the browser’s Local Storage or Session Storage.

Function Signature:

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

Parameters:

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

1. 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("myAppCounter", {
  // 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(initialCounterState);

// Subscribe to store changes to save them to Local Storage
persistentCounterStore.$act((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

2. 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("userSessionData");

const initialSessionData = getStoredSessionData({
  guestId: null,
  lastPage: "/",
});
const sessionDataStore = createStore(initialSessionData);

sessionDataStore.$act(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.

Function Signature:

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

Parameters:

Return Value: [getCookieStateFunction, setCookieStateFunction]

Example:

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

const cookieAdapter = createCookieStorageAdapter("appPreferences", {
  secret: "your-very-strong-secret-key-for-signing", // Recommended for security
  path: "/",
  secure: true,
  sameSite: "lax",
  maxAge: 3600 * 24 * 30, // 30 days
});

const [getCookiePrefs, setCookiePrefs] = cookieAdapter;

const initialPrefs = getCookiePrefs({
  theme: "light",
  notifications: true,
});
const prefsStore = createStore(initialPrefs);

prefsStore.$act((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.

Function Signature:

createBrowserStorageAdapter<State>(key: string, options: BrowserStorageAdapterOptions<State>)

Parameters:

Return Value: [getCustomStateFunction, setCustomStateFunction, removeCustomStateFunction]

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

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

// Example custom storage (can be async)
const myCustomStorage = {
  data: {} as Record<string, string>,
  getItem(key: string) {
    this.data[key];
  },
  removeItem(key: string) {
    delete this.data[key];
  },
  setItem(key: string, value: string) {
    this.data[key] = value;
  },
};

const [getCustomState, setCustomState, removeCustomState] =
  createBrowserStorageAdapter("myCustomDataKey", {
    getItem: myCustomStorage.getItem,
    setItem: myCustomStorage.setItem,
    removeItem: myCustomStorage.removeItem,
  });

// Example usage (assuming synchronous custom storage for simplicity here)
const initialCustomData = getCustomState({ lastSync: null });
const customDataStore = createStore(initialCustomData);

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

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.

Explore advanced usage

While createStore() is the recommended and most common way to create stores, @ibnlanre/portal also exports direct store creation functions for specific scenarios or finer control.

Use direct store creation functions

createPrimitiveStore()

Creates a store specifically for a single, primitive value (string, number, boolean, null, undefined, symbol, bigint).

Syntax:

createPrimitiveStore<S extends Primitives>(initialState: S): PrimitiveStore<S>

When to use:

Example:

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

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

isActiveStore.$set(true);

createCompositeStore()

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

Syntax:

createCompositeStore<S extends GenericObject>(initialState: S): CompositeStore<S>

When to use:

Example:

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

const userDetailsStore = createCompositeStore({
  username: "guest",
  permissions: { read: true, write: false },
});

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

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

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:

Follow best practices

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

Troubleshoot common issues

Here are some common issues and how to resolve them:

Problem Possible Cause Solution
Component not re-rendering after state change Store not updated via $set() or updater from $use(). Ensure all state modifications use the store’s update mechanisms.
  Selector in $use(selector, deps) has incorrect dependencies. Verify the deps array for selectors in $use().
  Using $get() in render path instead of $use(). Use $use() to subscribe React components to store changes. $get() does not subscribe.
  Selector captures external variables instead of store state. Ensure selectors depend only on the store state parameter, not external variables. Use store-level selectors for cross-store dependencies.
Derived state not updating reactively Using useMemo with incomplete dependencies. Include all store data in useMemo dependencies, or better yet, use $use() with a selector that depends on store state.
  External variables captured in selector closure. Rewrite selectors to depend on store state: store.$use(state => state.items.filter(item => item.ownedBy === state.selectedUserId)).
Array elements treated as stores Attempting to call store methods on array elements. Array elements are plain data. Use arrayStore.$get()[index].property instead of arrayStore[index].property.$get().
State not persisting Persistence adapter misconfigured (e.g., wrong key). Double-check adapter configuration (key, stringify, parse).
  Data not serializable (e.g., functions, Symbols). Ensure state is serializable or provide custom stringify/parse functions.
  Browser storage limits exceeded. Be mindful of storage limits (especially for cookies and localStorage).
Type errors with store Initial state type mismatch with usage. Ensure the type of initialState matches how you intend to use the store.
  Incorrect type in $set() or action payload. Verify types for updates and action arguments.
Asynchronous data not appearing in store Accessing store before Promise in createStore(promise) resolves. await createStore(promise) or use $act()/$use() which will update when the promise resolves.
  Promise rejected. Add error handling for the promise passed to createStore or for the async operation that provides data for $set().
oldState is undefined in $act This is expected on the initial immediate call of the subscriber. The first time a subscriber is called (if immediate is true or default), oldState will be undefined as there’s no “previous” state for that subscription yet.
Circular references causing issues While supported, complex interactions might still be tricky. Ensure normalizeObject is used if input objects are not plain data. Simplify state structure if possible.
Actions not updating state correctly Using this context instead of store variable. In action functions, use the store variable (e.g., myStore.property.$set(value)) instead of this.property.$set(value).
Functions/symbols missing after normalization normalizeObject() strips functions and symbol keys. This is expected behavior. Functions and symbol keys are filtered out. Handle them separately if needed.

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, the best place to start is the documentation provided in this README and the API Reference. If you have specific questions or need assistance with implementation, you can also check the examples provided in the repository or ask for help in the community. If you encounter issues or have questions:

  1. Check existing issues: Look through the GitHub Issues to see if your question has already been addressed.
  2. Open a new issue: If you can’t find a solution, open a new issue on GitHub. Provide as much detail as possible, including:
    • Version of @ibnlanre/portal.
    • Steps to reproduce the issue.
    • Relevant code snippets.
    • Error messages, if any.
  3. Discussions: Check the GitHub Discussions tab (if enabled for the repository) for community help, questions, and to share ideas.

License

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