A TypeScript state management library for React applications.
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.
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
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>
@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.
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:
$get
: Retrieve the current state.$set
: Update the state with a new value.$use
: A React hook for managing state within functional components.$sub
: Subscribe to state changes to react to updates.$tap
: Access deeply nested stores using a dot-separated string.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"
$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"
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 }
$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));
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();
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);
@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"
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 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`);
$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
@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();
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" });
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);
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.
@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.
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.
$use
HookIn 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.
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;
@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.
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:
key
: The key used to store the state in the web storage.stringify
: A function to serialize the state to a string. The default is JSON.stringify
.parse
: A function to deserialize the state from a string. The default is JSON.parse
.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);
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);
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: "/",
});
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.
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";
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:
sign(value: string, secret: string): string
value
: The cookie value to be signed.secret
: The secret key used to sign the cookie.const signedValue = cookieStorage.sign("value", "secret");
unsign(signedValue: string, secret: string): string
signedValue
: The signed cookie value to be unsigned.secret
: The secret key used to unsign the cookie.const originalValue = cookieStorage.unsign(signedValue, "secret");
getItem(key: string): string
key
: The key of the cookie to retrieve.const value = cookieStorage.getItem("key");
setItem(key: string, value: string): void
key
: The key of the cookie to set.value
: The value to set for the cookie.cookieStorage.setItem("key", "value");
removeItem(key: string): void
key
: The key of the cookie to remove.cookieStorage.removeItem("key");
clear(): void
cookieStorage.clear();
createKey(options: Object): string
options.cookieFragmentDescription
: The description of the cookie fragment.options.cookiePrefix
: The prefix to use for the cookie key. Default is "__"
.options.cookieFragmentSizes
: The sizes of the cookie fragments. Default is []
.options.cookieScope
: The scope of the cookie. Default is "host"
.options.cookieScopeCase
: The case of the cookie scope. Default is "title"
.options.cookieService
: The service to use for the cookie. Default is ""
.options.cookieScopeServiceConnector
: The connector to use for the cookie scope and service. Default is "-"
.options.cookieScopeFragmentConnector
: The connector to use for the cookie scope and fragment. Default is "_"
.options.cookieFragmentsConnector
: The connector to use for the cookie fragments. Default is ""
.options.cookieSuffix
: The suffix to use for the cookie key. Default is ""
.const key = cookieStorage.createKey({
cookieFragmentDescription: "Authentication Token",
cookiePrefix: "__",
cookieFragmentSizes: [2, 3],
cookieScopeCase: "title",
cookieScope: "host",
});
key(index: number): string
index
: The index of the cookie to retrieve the key for.const key = cookieStorage.key(0);
length: number
const length = cookieStorage.length;
All contributions are welcome and appreciated. Thank you! 💚
This library is licensed under the BSD-3-Clause. See the LICENSE file for more information.