portal

@ibnlanre/portal

A TypeScript state management library for React applications.

Table of Contents

Installation

To install @ibnlanre/portal, you can use either a CDN, or a package manager. Run the following command within your project directory. The library is written in TypeScript and ships with its own type definitions, so you don’t need to install any type definitions.

Using Package Managers

If you are working on a project that uses a package manager, you can copy one of the following commands to install @ibnlanre/portal using your preferred package manager, whether it’s npm, pnpm, or yarn.

npm

npm i @ibnlanre/portal

pnpm

pnpm add @ibnlanre/portal

yarn

yarn add @ibnlanre/portal

Using a CDN

If you are working on a project that uses markup languages like HTML or XML, you can copy one of the following script tags to install @ibnlanre/portal using your preferred CDN:

skypack

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

unpkg

<script src="https://unpkg.com/@ibnlanre/portal"></script>

jsDelivr

<script src="https://cdn.jsdelivr.net/npm/@ibnlanre/portal"></script>

Usage

@ibnlanre/portal simplifies state management with its intuitive and developer-friendly API. Built with scalability and flexibility in mind, it integrates seamlessly with React applications while remaining lightweight and easy to use. Managing application state should not be a complex task, and @ibnlanre/portal is designed to make the process effortless, even in existing codebases.

Managing State

State management with @ibnlanre/portal begins with the createStore function. This function initializes a store with an initial value and returns an object containing methods to interact with the state: $get, $set, $use, $sub, and $tap.

These methods provide a simple and consistent way to access, update, and subscribe to state changes. Here’s an example of creating a store:

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

const store = createStore("initial value");

Each method serves a distinct purpose:

Accessing State with $get

The $get method provides a straightforward way to access the current state synchronously. It’s ideal when you need to read the state without setting up a subscription.

const value = store.$get();
console.log(value); // "initial value"

You can also pass a callback function to $get to modify the state before accessing it. The callback receives the current state and returns the modified value. This lets you work with a transformed state without altering the actual store.

const modifiedValue = store.$get((value) => `${value} modified`);
console.log(modifiedValue); // "initial value modified"

Updating State with $set

The $set method updates the state with a new value. Simply pass the desired value to this method, and it immediately replaces the current state.

store.$set("new value");
const newValue = store.$get(); // "new value"

Alternatively, $set can accept a callback function that takes the previous state as an argument and returns the updated state. This approach is useful for making updates that depend on the previous state.

store.$set((prev) => `${prev} updated`);
const updatedValue = store.$get(); // "new value updated"

Partial State Updates

The $set method also supports partial state updates when the state is an object. You can pass an object containing the properties to update, and $set will merge the new properties with the existing state.

const store = createStore({ name: "John", age: 30 });

store.$set({ age: 31 });
const updatedState = store.$get(); // { name: "John", age: 31 }

Subscribing to State Changes with $sub

In addition to accessing and updating state, @ibnlanre/portal provides the $sub method to subscribe to state changes. This allows you to react to updates and perform side effects when the state changes.

store.$sub((value) => console.log(value));

Unsubscribing from State Changes

The $sub method returns an unsubscribe function that can be called to stop listening for updates. This is particularly useful when a component unmounts or when you no longer need the subscription.

const unsubscribe = store.$sub((value) => {
  console.log(value);
});

// Stop listening to state changes
unsubscribe();

Preventing Immediate Callback Execution

By default, the $sub callback is invoked immediately after subscribing. To disable this behavior, pass false as the second argument. This ensures the callback is only executed when the state changes.

store.$sub((value) => {
  console.log(value);
}, false);

Nested Stores

@ibnlanre/portal supports the creation of nested stores, allowing you to manage deeply structured state with ease. To create a nested store, pass an object containing nested objects, arrays, or primitive values to the createStore function. Each node in the nested structure becomes its own store, with access to methods for updating and managing its state.

This design enables fine-grained control over state at any level of the object hierarchy.

const store = createStore({
  location: {
    unit: "Apt 1",
    address: {
      street: "123 Main St",
      city: "Springfield",
    },
  },
});

const city = store.location.address.city.$get();
console.log(city); // "Springfield"

Breaking Off Stores

Remember that each point in an object chain is a store. This means that each member of the chain has access to its own value and can be broken off into its own store at any point in time. This is useful when you want to work with a nested state independently of the parent store.

For example, you can break off the address store from the location store and work with it independently:

const { address } = store.location;
address.street.$get(); // "123 Main St"

const { street } = address;
street.$set("456 Elm St");

Updating Nested Stores

Updating the state of a nested store works the same way as updating a regular store. You can use the $set method to change the value of a nested store. Since all levels in the hierarchy share the same underlying state, updates made to a nested store will propagate to the parent store.

This behavior is by design, allowing you to work with any part of the state seamlessly.

const { street } = store.location.address;

street.$set("456 Elm St");
street.$set((prev) => `${prev} Apt 2`);

Accessing Nested Stores with $tap

To conveniently access deeply nested stores, you can use the $tap method with a dot-separated string. This method leverages TypeScript’s IntelliSense for path suggestions, providing an overview of the available nested stores. This makes managing complex state structures more intuitive and efficient.

const street = store.$tap("location.address.street");
street.$get(); // 456 Elm St

The $tap method returns a reference to the nested store, which means you can access and update the nested state directly. You also can tap at any level of the hierarchy, allowing you to work with deeply nested stores without the need for manual traversal.

store.$tap("location.address.street").$set("789 Oak St");

const { street } = store.location.$tap("address");
street.$get(); // 789 Oak St

Actions

@ibnlanre/portal allows you to store plain functions alongside primitives and objects. When the initial state is an object, functions within it are not converted into nested stores but remain callable as methods. This approach simplifies managing complex state transitions, co-locates logic with state, and reduces boilerplate in larger applications.

const count = createStore({
  value: 0,
  increase(amount: number = 1) {
    count.value.$set((prev) => prev + amount);
  },
  decrease(amount: number = 1) {
    count.value.$set((prev) => prev - amount);
  },
  reset() {
    count.value.$set(0);
  },
});

count.increase(5);
count.reset();

Reducer Pattern

One thing to note is that you have full control over how the function is defined, named, and structured, including its interaction with the store, the format of its arguments and whether it is nested. This flexibility allows you to implement a reducer pattern that aligns with your judgment and preferences.

type CountControlAction = {
  type: "increase" | "decrease" | "reset";
  amount?: number;
};

const count = createStore({
  value: 0,
  dispatch({ type, amount = 1 }: CountControlAction) {
    switch (type) {
      case "increase":
        count.value.$set((prev) => prev + amount);
        break;
      case "decrease":
        count.value.$set((prev) => prev - amount);
        break;
      case "reset":
        count.value.$set(0);
        break;
    }
  },
});

count.dispatch({ type: "increase", amount: 5 });
count.dispatch({ type: "reset" });

Internal Actions

In scenarios, where you do not want the actions to be exposed as methods through the store, you can create regular functions that interacts with the store. These functions can then be called directly or used as a callback in other functions.

export const count = createStore({
  value: 0,
});

function increase(amount: number = 1) {
  count.value.$set((prev) => prev + amount);
}

function decrease(amount: number = 1) {
  count.value.$set((prev) => prev - amount);
}

function reset() {
  count.value.$set(0);
}

externalService.on("connect", increase);
externalService.on("disconnect", decrease);
externalService.on("error", reset);

Asynchronous State

The createStore function also supports asynchronous state initialization. You can pass an async function that returns a Promise resolving to the initial state. This is particularly useful for scenarios where the initial state needs to be fetched from an external API.

Note that the store will remain empty until the Promise resolves. Also, if the resolved value is an object, it will be treated as a primitive value, not as a nested store. This is because nested stores are only created during initialization from objects directly passed to createStore.

Here’s an example:

type State = { apartment: string };

async function fetchData(): Promise<State> {
  const response = await fetch("https://api.example.com/data");
  return response.json();
}

// Create a store with an asynchronous initial state
const store = await createStore(fetchData);

const state = store.$get();
console.log(state); // { apartment: "123 Main St" }

By combining nested stores and asynchronous initialization, @ibnlanre/portal enables you to manage state efficiently in even the most dynamic applications.

React Integration

@ibnlanre/portal offers first-class support for React applications, making state management seamless within functional components. When you create a store, it automatically provides a React hook called $use. This hook is similar to the useState hook in React, returning an array with two elements: the current state value and a dispatch function to update the state.

Here’s how you can integrate the $use hook into your React components:

import type { ChangeEvent } from "react";
import { store } from "./path-to-your-store";

function Component() {
  const [state, setState] = store.$use();

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setState(event.target.value);
  };

  return <input value={state} onChange={handleChange} />;
}

export default Component;

The $use hook eliminates the need for manual subscriptions to state changes. It ensures that the component automatically updates whenever the store’s state changes and gracefully unsubscribes when the component unmounts. This simplifies state management within your application.

Modifying State with a Callback

Like the $get and $set methods, the $use hook can accept a callback function as its first argument. This function is called after the state is retrieved from the store but before it is returned to the component. This allows you to transform or modify the state before using it.

const [state, setState] = store.$use((value) => `${value} modified`);

Note that the callback function does not alter the store’s state. Instead, it modifies the value returned for use within the component. Also, the dispatch function (setState) expects values of the same type as the store’s initial state, ensuring type safety.

Using Dependencies with the $use Hook

In cases where the result of the callback function depends on other mutable values, you can pass a dependency array as the second argument to the $use hook. This ensures the callback function is only re-evaluated when dependencies change.

const [count, setCount] = useState(0);

const [state, setState] = store.$use(
  (value) => `Count: ${count} - ${value}`,
  [count]
);

Note that this dependency array works like that in React’s useEffect or useMemo hooks. The callback function is memoized and only re-executed when the specified dependencies change. Also, the modified state returned by the callback is independent of the store’s internal state. This ensures that the store’s state remains unchanged.

Partial State Updates

The setState function in the $use hook also supports partial state updates when the state is an object. You can pass an object containing the properties to update, and setState will merge the new properties with the existing state.

const store = createStore({ name: "John", age: 30 });

function Component() {
  const [state, setState] = store.$use();

  const updateAge = () => {
    setState({ age: 31 });
  };

  return (
    <div>
      <p>{state.name}</p>
      <p>{state.age}</p>
      <button onClick={updateAge}>Update Age</button>
    </div>
  );
}

export default Component;

Persistence

@ibnlanre/portal provides storage adapters for local storage, session storage, and cookies. These adapters allow you to persist state across sessions and devices. The storage adapters are created using the createLocalStorageAdapter, createSessionStorageAdapter, and createCookieStorageAdapter functions.

Each function takes a key as a parameter and returns two functions: one to get the state and the other to set the state. The createStore function can then be used with these storage adapters to create a store that persists state in the web storage.

Web Storage Adapter

To persist state within the local/session storage of the browser, you can use either the createLocalStorageAdapter or createSessionStorageAdapter functions respectively. The parameters for these functions are the same. They are as follows:

Local Storage Adapter

The only required parameter for the web storage adapters is the key. This is the key used to store the state in the storage, as well as, to retrieve and update the state from the storage. It’s important to note that the key is unique to each store, and should be unique to prevent conflicts with other stores.

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

const [getLocalStorageState, setLocalStorageState] = createLocalStorageAdapter({
  key: "storage",
});

Through the stringify and parse functions, you can customize how the state is serialized and deserialized. This is useful when working with non-JSON serializable values or when you want to encrypt the state before storing it in the web storage.

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

const [getLocalStorageState, setLocalStorageState] = createLocalStorageAdapter({
  key: "storage",
  stringify: (state) => btoa(JSON.stringify(state)),
  parse: (state) => JSON.parse(atob(state)),
});

The benefit of using the storage adapters is that they can be used to automatically load the state from the storage when the store is being created. This allows you to initialize the store with the persisted state.

const store = createStore(getLocalStorageState);

To persist the state in the local storage, you can subscribe to the store changes and update the storage with the new state. This ensures that the state is always in sync with the local storage.

store.$sub(setLocalStorageState);

Session Storage Adapter

The sessionStorage adapter works similarly to the localStorage adapter. The only difference is that the state is stored in the session storage instead of the local storage. This is useful when you want the state to persist only for the duration of the session.

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

const [getSessionStorageState, setSessionStorageState] =
  createSessionStorageAdapter({
    key: "storage",
  });

const store = createStore(getSessionStorageState);
store.$sub(setSessionStorageState);

Note that both getLocalStorageState and getSessionStorageState are functions that can take an optional fallback state as an argument. This allows you to provide a default state when the state is not found in the storage.

const store = createStore(() => getLocalStorageState("initial value"));
store.$sub(setLocalStorageState);

Browser Storage Adapter

Although the storage adapters provided by the library are a select few, if you need to use a different storage mechanism, such as IndexedDB or a custom API, you can create your own browser storage adapter. The browser storage adapter only requires a key, and functions to get the item, set the item, and remove the item from the storage. Other options like stringify and parse are optional.

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

const storage = new Storage();

const [getStorageState, setStorageState] = createBrowserStorageAdapter({
  key: "storage",
  getItem: (key: string) => storage.getItem(key),
  setItem: (key: string, value: string) => storage.setItem(key, value),
  removeItem: (key: string) => storage.removeItem(key),
});

The last of the storage adapters provided by the library is the cookie storage adapter. It is created using the createCookieStorageAdapter function, which takes a key and an optional configuration object with cookie options. These options are similar to those provided by the document.cookie API.

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

const [getCookieStorageState, setCookieStorageState] =
  createCookieStorageAdapter({
    key: "storage",
    domain: "example.com",
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: "/",
  });

Signed Cookies

To enhance security, you can provide a secret parameter to sign the cookie value. This adds an extra layer of protection, ensuring the cookie value has not been tampered with. To use the secret value, set signed to true.

Note that an error will be thrown if signed is set to true and the secret is not provided. To prevent this, ensure that the secret is provided or set signed to false to disable signing.

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

const secret = process.env.COOKIE_SECRET_KEY;

if (!secret) {
  throw new Error("Cookie secret key is required");
}

const [getCookieStorageState, setCookieStorageState] =
  createCookieStorageAdapter({
    key: "storage",
    sameSite: "Strict",
    signed: !!secret,
    secret,
  });

One key difference between the createCookieStorageAdapter function and the other storage adapter functions is that its setCookieStorageState function takes an additional parameter: the cookie options. This allows you to update the initial cookie options when setting the cookie value. This is useful when you want to update the max-age or expires options of the cookie.

const store = createStore(getCookieStorageState);

store.$sub((value) => {
  const expires = new Date(Date.now() + 15 * 60 * 1000);

  setCookieStorageState(value, {
    secure: true,
    partitioned: false,
    httpOnly: true,
    expires,
  });
});

One key module provided by the library is the cookieStorage module. This module provides a set of functions to manage cookies in a web application. Just like localStorage and sessionStorage, cookies are a way to store data in the browser.

Motivation

Cookies are useful for storing small amounts of data that need to be sent with each request to the server. However, most modern web browsers do not have a means to manage cookies, and this is where the cookieStorage module comes in. Accessing the cookieStorage module is similar to accessing the other modules provided by the library.

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

Utility Functions

All functions in the cookieStorage module are static and do not require an instance of the module to be created. This is because the module is a utility module that provides a set of functions to manage cookies. The following are the functions provided by the cookieStorage module:

Contributions

All contributions are welcome and appreciated. Thank you! 💚

License

This library is licensed under the BSD-3-Clause. See the LICENSE file for more information.