npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

react-thermals

v4.0.2

Published

Simple and type-safe way to manage shared state based on React hooks

Downloads

154

Readme

NPM Link Language Build Status Code Coverage Gzipped Size Dependency details Tree shakeable ISC License

React Thermals is a simple and type-safe way to manage shared state in React

npm install react-thermals

Table of contents

  1. Features
    1. Changelog
    2. Roadmap
  2. Core concepts
    1. Properties and paths
    2. Selectors
    3. Immutability
    4. Persistence
  3. Example usage
    1. Example 1: A store used by multiple components
    2. Example 2: A store used by one component
    3. Example 3: A store with global state
  4. Action functions
    1. Writing actions
    2. Action creators
    3. Action batching
    4. Asynchronous actions
    5. Synchronous actions
  5. Strongly typed state
    1. TypeScript definitions
  6. Store class documentation
    1. Constructor
    2. State Setters
    3. State Getters
    4. Most common store methods
    5. Other store methods
  7. Best Practices
    1. Code splitting
    2. Suggested file structure
    3. Testing stores
  8. Extending store behavior
    1. Events
    2. Plugins
    3. Middleware
  9. Community
    1. Contributing
    2. ISC license
    3. Credits

Features

  1. Instead of dispatchers or observables, define simple action functions with no boilerplate
  2. Components only re-render when relevant state changes
  3. Promises are first-class citizens (state changes can be wrapped in Promises)
  4. A store can be used by one component or many components
  5. Path expressions make it super easy to deal with immutable data structures
  6. Include stores only in the components that need them
  7. Stores persist data even if all consumers unmount (optional)
  8. Stores allow worry-free code splitting
  9. Store actions are easily testable
  10. Store state is strongly typed using TypeScript Generics
  11. Stores can respond to component lifecycle events including unmount (e.g. to abort fetching data)
  12. No Context Provider components are needed in <App /> or elsewhere

Also see the changelog and roadmap.

Core Concepts

Properties and Paths

React Thermals supports property names and path expressions in 4 use cases:

  1. Selecting state inside a component
  2. Updating values in the store
  3. Creating store actions
  4. Reading state from the store (uncommon)

Example of these 4 use cases:

// 1. Selecting state from the store (inside a component)
const recipients = useStoreState(store, 'email.recipients');

// 2. Updating values in the store
store.setStateAt('email.recipients', recipients);

// 3. Creating store action functions
const addRecipient = store.connect('email.recipients', appender());

// 4. Reading state from the store (uncommon)
const recipients = store.getStateAt('email.recipients');

Path expression examples:

  • user - The value of user property
  • user.name - The name property on the user object
  • users[2].id - The id of the 3rd user object
  • users.2.id - (same as above)
  • users[*].isActive - The isActive property of every user object
  • users.*.isActive - (same as above)
  • books[*].authors[*].name - The name property of every author object within every book object

Selectors

Selectors ensure that components will re-render only when a relevant part of state changes.

Selectors work the same as selectors work in Redux and you can use libraries such as reselect or re-reselect with React Thermals to manage selectors. However, as you will see below, React Thermals supports path expressions and arrays of paths that often remove the need for complex selectors.

Selector examples

// 2 ways to select the value of a single field
const todos = useStoreSelector(myStore, state => state.todos);
const todos = useStoreSelector(myStore, 'todos');

// 2 ways to select a deeper value
const userId = useStoreSelector(myStore, state => state.user.id);
const userId = useStoreSelector(myStore, 'user.id');

// 2 ways to select a list of deeper values
const bookIds = useStoreSelector(myStore, state => state.books.map(b => b.id));
const bookIds = useStoreSelector(myStore, 'books[*].id');

// 2 ways to select multiple fields using an array
const [sender, recipients] = useStoreSelector(myStore, [
  state => state.sender,
  state => state.recipients,
]);
const [sender, recipients] = useStoreSelector(myStore, [
  'sender',
  'recipients',
]);

// 2 ways to select multiple deeper values using an array
const [senderEmail, recipientsEmails] = useStoreSelector(myStore, [
  state => state.sender.email,
  state => state.recipients.map(r => r.email),
]);
const [senderEmail, recipientsEmails] = useStoreSelector(myStore, [
  'sender.email',
  'recipients[*].email',
]);

// Use a mix of selector types
const [subject, sender, recipientsEmails] = useStoreSelector(myStore, [
  'subject',
  state => state.sender,
  'recipients[*].email',
]);

If your component would like to receive the entire state, you can utilize useStoreSate(myStore) which acts like useStoreSelector but selects the whole state. Note that your component will re-render any time any part of the state changes.

TypeScript goodness

Thanks to Sindre Sorhus's awesome type-fest package, you will get autocomplete, type inference, and type checking on methods that have a path argument (getStateAt, setStateAt, mergeStateAt, resetStateAt).

Example:

// (react thermals will infer type if not given)
type GlobalSchemaType = {
  hello?: {
    world: number;
  };
  foo?: string;
};
const initialState: GlobalSchemaType = { hello: { world: 42 } };
const store = new Store(initialState);
store.mergeState({ foo: 'bar' });
store.getStateAt('hello.world'); // TypeScript knows return value is number
store.setStateAt('hello.world', 'me'); // TypeScript knows "me" is invalid
store.setStateAt('hello.world', () => 'me'); // TypeScript knows "me" is invalid
store.setStateAt('foo', () => 42); // TypeScript knows 24 is invalid

However, note that when your path contains an asterisk, TypeScript will not understand that the preceding element is an array.

Immutability

Stores should treat state as immutable. When using path expressions for actions or calling setStateAt, React Thermals automatically ensures relevant parts of state are replaced instead of changed. Replacing is more efficient than cloning the entire state and ensures that components re-render only when replaced parts of the state change.

Under the hood, React Thermals has a replacePath(path, value) function that performs this state replacement. The unit test below illustrates a change to a multi-layer state value, where the resulting state has some changes but keeps unaffected parts unchanged.

describe('updatePath', () => {
  it('should only update relevant parts of state', () => {
    const state = {
      email: {
        subject: 'hello',
        sender: { id: 3, name: 'Otto' },
        recipients: [
          { id: 1, name: 'John' },
          { id: 2, name: 'Josh' },
        ],
      },
    };
    const addRecipient = updatePath(
      'email.recipients',
      (recipients, newRecipient) => {
        return [...recipients, newRecipient];
      }
    );
    const newRecipient = { id: 4, name: 'Lili' };
    const updated = addRecipient(state, newRecipient);
    expect(updated).not.toBe(state); // different object
    expect(updated.email).not.toBe(state.email); // different object
    expect(updated.email.subject).toBe(state.email.subject); // same value
    expect(updated.email.sender).toBe(state.email.sender); // same object!
    expect(updated.email.recipients).not.toBe(state.email.recipients);
    expect(updated.email.recipients[0]).toBe(state.email.recipients[0]); // same object!
    expect(updated.email.recipients[1]).toBe(state.email.recipients[1]); // same object!
    expect(updated.email.recipients[2]).toBe(newRecipient); // our newly added recipient
  });
});

Persistence

By default, a store's state value will persist even when all consumer components unmount. To reset the state instead, add autoReset: true to the store definition and the state will automatically revert back to its initial value after all components unmount.

const autoResettingStore = new Store(initialState, {
  // ...
  autoReset: true,
});

Example usage

React Thermals is designed for multiple use cases:

  1. Example 1: A store used by multiple components
  2. Example 2: A store used by one component
  3. Example 3: A store with global state

Example 1: A store used by multiple components

In src/stores/cartStore.js we define a single store that is only used by the parts of the application that deal with a shopping cart.

import {
  Store,
  useStoreSelector,
  appender,
  remover,
  setter,
  composeActions,
} from 'react-thermals';

const store = new Store({
  items: [],
  discount: 0,
});

export const addToCart = store.connect(
  composeActions([
    appender('items'),
    newItem => {
      axios.post('/api/v1/carts/item', newItem);
    },
  ])
);

export const removeFromCart = store.connect(
  composeActions([
    remover('items'),
    oldItem => {
      axios.delete(`/api/v1/carts/items/${oldItem.id}`);
    },
  ])
);

export const setDiscount = store.connect('discount', setter());

export function useCartItems() {
  return useStoreSelector(store, 'items');
}

export function useCartItemCount() {
  return useStoreSelector(store, state => state.items.length);
}

export function useCartTotal() {
  return useStoreSelector(store, state => {
    let total = 0;
    state.items.forEach(item => {
      total += item.quantity * item.price * (1 - state.discount);
    });
    return total;
  });
}

In components/Header.jsx we may want to show how many items are in the cart

import React from 'react';
import { useCartItemCount } from '../stores/cartStore';

export default function Header() {
  // only re-render when cart item count changes
  const itemCount = useCartItemCount();
  return (
    <header>
      <h1>My App</h1>
      <a href="/cart">
        Shopping Cart: {itemCount} {itemCount === 1 ? 'item' : 'items'}
      </a>
    </header>
  );
}

In components/CartDetails.jsx we need the items, the total, and a way to remove an item from the cart.

import React from 'react';
import {
  useCartItems,
  useCartTotal,
  removeFromCart,
} from '../stores/cartStore';

export default function CartDetails() {
  // only re-render when list or total changes
  const items = useCartItems();
  const total = useCartTotal();
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}: ${item.price.toFixed(2)}{' '}
          <button onClick={() => removeFromCart(item)}>Delete</button>
        </li>
      ))}
      <li>Total: ${total.toFixed(2)}</li>
    </ul>
  );
}

In components/Product.jsx we don't need info about the cart, but we may need to add an item to the cart.

import React from 'react';
import { addToCart } from '../stores/cartStore';

export default function Product({ product }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <p>${product.price.toFixed(2)}</p>
      <button onClick={() => addToCart(product)}>Add to cart</button>
    </div>
  );
}

In stores/cartStore.spec.js we can test that actions change state in the correct way and test any side effects like an http request.

import axios from 'axios';
import { default as cartStore } from './cartStore';

jest.mock('axios');

describe('cartStore', () => {
  let store;
  beforeEach(() => {
    store = cartStore.clone();
  });
  it('should add item', async () => {
    const item = { id: 123, name: 'Pencil', price: 2.99 };
    store.actions.add(item);
    await store.nextState();
    expect(store.getState().items[0]).toBe(item);
    expect(axios.post).toHaveBeenCalledWith('/api/v1/carts/item', item);
  });
  it('should remove item', async () => {
    const item = { id: 123, name: 'Pencil', price: 2.99 };
    store.setStateAt('items', [item]);
    store.actions.remove(item);
    await store.nextState();
    expect(store.getState().items).toEqual([]);
    expect(axios.delete).toHaveBeenCalledWith(`/api/v1/carts/items/${item.id}`);
  });
});

Example 2: A store used by one component

Even if a store is only used by one component, it can be a nice way to separate concerns. And the store object itself doesn't necessarily need to be exported. You might want your application to interact with the store only through hooks and functions exported by your store file.

In components/Game/gameStore.ts

import { Store, useStoreState } from 'react-thermals';
import random from 'random-int';

const store = new Store({
  board: {
    user: { x: 0, y: 0 },
    flag: { x: random(1, 10), y: random(1, 10) },
  },
  hasWon: false,
});

export function restart() {
  store.reset();
  store.setStateAt('board.flag', {
    x: random(1, 10),
    y: random(1, 10),
  });
}

export function moveBy(x: number, y: number): void {
  store.setStateAt('board.user', old => ({
    x: old.x + x,
    y: old.y + y,
  }));
  const { user, flag } = store.getStateAt('board');
  if (flag.x === user.x && flag.y === user.y) {
    store.mergeState({ hasWon: true });
  }
}

export function useGameState() {
  return useStoreState(store);
}

In components/Game/Game.tsx

import React from 'react';
import range from '../range';
import './Game.css';
import { useGameState, restart, moveBy } from './gameStore';

export default function Game(): React.Element {
  const state = useGameState();

  return (
    <div className="Game">
      <h1>Hop to the flag</h1>
      <div className="board">
        {range(11).map((x: number) => (
          <div key={`x-${x}`} className="row">
            {range(11).map((y: number) => (
              <div key={`y-${y}`} className="cell">
                {state.board.user.x === x && state.board.user.y === y
                  ? '🐸'
                  : state.board.flag.x === x &&
                    state.board.flag.y === y &&
                    '⛳️'}
              </div>
            ))}
          </div>
        ))}
      </div>
      {state.hasWon ? (
        <div className="you-win">
          You win!
          <button onClick={restart}>New game</button>
        </div>
      ) : (
        <div className="controls">
          <button onClick={() => moveBy(0, -1)}>←</button>
          <button onClick={() => moveBy(-1, 0)}>↑</button>
          <button onClick={() => moveBy(1, 0)}>↓</button>
          <button onClick={() => moveBy(0, 1)}>→</button>
        </div>
      )}
    </div>
  );
}

Example 3: A store with global state

We create a store in stores/globalStore/globalStore.js

import { Store, useStoreSelector } from 'react-thermals';
const globalStore = new Store();

export default globalStore;

export function useGlobalStore(selector) {
  return useStoreSelector(globalStore, selector);
}

In stores/globalStore/slices/todos.js we extend the store's state with a "todos" property.

import globalStore, { useGlobalStore } from '../globalStore';
import { persistState, appender, merger, remover } from 'react-thermals';

// extend the state at any time
globalStore.initState(old => ({ ...old, todos: [] }));

// add actions at any time
export const addTodo = globalStore.connect('todos', appender());
export const replaceTodo = globalStore.connect('todos', replacer());
export const removeTodo = globalStore.connect('todos', remover());

// you can provide a hook for conveniently selecting this state
export function useTodos() {
  // The string 'todos' is equivalent to state => state.todos
  return useGlobalStore('todos');
}
// ...or a hook to select parts of the state
export function useTodoIncompleteCount() {
  return useGlobalStore(state => {
    return state.todos.filter(todo => !todo.isComplete).length;
  });
}

// add plugins to the root store at any time
globalStore.plugin(
  persistState({
    storage: localStorage,
    key: 'myTodos',
    fields: ['todos'],
  })
);

In components/Header.jsx we may only care about the TODO incomplete count

import React from 'react';
import { useTodoIncompleteCount } from '../stores/globalStore/slices/todos';

export default function Header() {
  const incompleteCount = useTodoIncompleteCount();
  return (
    <header>
      <h1>My Tasks</h1>
      <div>Tasks remaining: {incompleteCount}</div>
    </header>
  );
}

In components/TodoList.jsx we need to render the whole TODO list and provide a way to toggle completeness and delete a todo

import React from 'react';
import useTodos, {
  toggleTodoComplete,
  removeTodo,
} from '../stores/globalStore/slices/todos';
import NewTodoForm from './NewTodoForm.jsx';

export default function TodoList() {
  const todos = useTodos();
  return (
    <ul>
      {todos.map((todo, i) => (
        <li key={i}>
          <input
            type="checkbox"
            checked={todo.isComplete}
            onClick={() => toggleTodoComplete(todo)}
          />
          <span className="text">{todo.text}</span>
          <span onClick={() => removeTodo(todo)}>Delete</span>
        </li>
      ))}
      <li>
        <NewTodoForm />
      </li>
    </ul>
  );
}

In components/NewTodoForm.jsx we don't need any state, but we do need to access the action for adding a TODO.

import React, { useCallback } from 'react';
import { addTodo } from '../stores/globalStore/slices/todos';

export default function NewTodoForm() {
  const addTodoAndClear = useCallback(evt => {
    evt.preventDefault();
    const form = evt.target;
    const data = new FormData(form);
    const newTodo = Object.fromEntries(data);
    newTodo.isComplete = false;
    form.reset();
    addTodo(newTodo);
  }, []);
  return (
    <form onSubmit={addTodoAndClear}>
      <input name="text" placeholder="Enter todo..." />
      <button type="submit">Add</button>
    </form>
  );
}

In stores/globalStore/slices/auth.js we extend the store's state with a "user" property.

import axios from 'axios';
import globalStore, { useGlobalStore } from '../../globalStore/globalStore';
import { setterInput } from 'react-thermals';

export function useAuth() {
  return useGlobalStore('user');
}

// initState updates state but avoids rerendering
globalStore.initState(old => ({
  ...old,
  user: {
    isLoggedIn: false,
    isCheckingLogin: false,
  },
}));

// actions can be async
export async function login(form) {
  const formData = Object.fromEntries(new FormData(form));
  globalStore.setStateAt('user', {
    isLoggedIn: false,
    isCheckingLogin: true,
  });
  const { data } = await axios.post('/api/users/login', formData);
  localStorage.setItem('jwt', data.jwt);
  globalStore.mergeStateAt('user', {
    ...data.user,
    isLoggedIn: true,
    isCheckingLogin: false,
  });
}

In components/Login/Login.jsx we need to know information about the user and connect the login action to a form submission.

import { useAuth, login } from '../../stores/slices/auth';
import Loader from '../Loader/Loader.jsx';

export default function Login() {
  const { isCheckingLogin } = useAuth();
  return (
    <div className="LoginComponent">
      {isCheckingLogin ? (
        <Loader />
      ) : (
        <form
          onSubmit={evt => {
            evt.preventDefault();
            login(evt.target);
          }}
        >
          <input name="email" type="input" placeholder="Email" />
          <input name="password" type="password" placeholder="Password" />
          <button type="submit">Login</button>
        </form>
      )}
    </div>
  );
}

In components/SubHeader.jsx we might show the user's name or a link to log in.

import React from 'react';
import { useAuth } from '../stores/slices/auth';

export default function SubHeader() {
  const user = useAuth();
  return (
    <header>
      <h2>My App</h2>
      {user.isLoggedIn ? (
        <span>Hello {user.name}</span>
      ) : (
        <a href="/login">Login</a>
      )}
    </header>
  );
}

Action Functions

Writing Actions

For many actions, you can use action creators as introduced in the next section and as documented here.

Otherwise, you have the following building blocks to write your own actions. store.setState works exactly like a setter function from a useState() pair. store.mergeState works similarly, except the store will merge current state with the partial state passed to mergeState--with the assumption that the current state and new state are both Arrays or both plain Objects.

Calling state.setState will trigger a re-render on all components that consume the whole state and components that consume selected state that changes.

Note that by default, state persists even when all consumers have unmounted. The effect is similar to having a global state that your top level <App /> consumes. To disable persistence, create the state with autoReset set to true.

Many cross-component state patterns like Redux do not have built-in ways to code split. In React Thermals, code splitting happens naturally because components must import any stores they want to consume.

Action creators

For common types of state changes, React Thermals has several functions that will create action functions. Supported state changes are:

  1. setter(path) - Set a single value
  2. toggler(path) - Toggle a boolean value
  3. appender(path) - Append an item to a list
  4. remover(path) - Remove an item from a list
  5. replacer(path) - Replace an item in a list (i.e. edit)
  6. adder(path) - Add to or subtract from a number
  7. merger(path) - Merge one object into another
  8. fetcher({ path, url, init, extractor }) - Fetch and store data from an API

There are also two functions for combining action functions:

  1. composeActions(actions) - Run multiple actions where one action doesn't depend on the changes from another
  2. pipeActions(actions) - Run multiple actions in sequence where one action's change depends on another

Full docs on action creators.

Asynchronous Actions

When a setter function receives a promise or a function that returns a promise, React Thermals will automatically await that value. If more than one promise is batched for changes, they will be awaited serially, such that a promise operates on the resolved state of the previous promise; and re-renders will not be triggered until all batched promises have resolved.

Keep in mind that middleware may perform further state changes synchronously or asynchronously.

You can use await store.nextState() to take action when the next state is resolved and all affected components have been re-rendered.

Synchronous Actions

When a setter function receives a value or a function that returns a value, React Thermals will synchronously trigger a re-render.

Keep in mind that a middleware that executes asynchronously will make all actions asynchronous. That could be a problem, for example, if an action responds to an <input onChange={action} /> event where the user's keyboard cursor will not work as intended unless re-renders are synchronous. In that case, be sure that all middleware is synchronous.

Store Class Documentation

Constructor

new Store(state, options) takes an options object with the following properties:

  • autoReset boolean - If true, reset the store when all consumer components unmount (default false)
  • id string - An identifier that could be used by plugins or event listeners

State Setters

The following state setters will cause update state with a value or function that takes old state and returns new state.

| Action | Update whole state | Update state at path | Rerender? | | ------ | -------------------------- | ---------------------------------- | --------- | | Set | setState(value, options) | setStateAt(path, value, options) | Yes | | Merge | mergeState(value, options) | mergeStateAt(path, value, options) | Yes | | Reset | resetState(options) | resetStateAt(value, options) | Yes | | Init | initState(options) | initStateAt(value, options) | No |

The value parameter supports values, promises, functions that return values, and functions that return promises.

Examples:

store.setState(42);
store.setState(old => old + 42);
store.setState(Promise.resolve(42));
store.setState(old => Promise.resolve(old + 42));

Bypassing middleware, rendering and AfterUpdate event

The options parameter allows you to replace the state value without the usual effects. This can be useful for plugins or testing, but not generally necessary.

That options object has up to 4 properties:

  1. bypassRender - If true, do not notify components of the change.
  2. bypassMiddleware - If true, skip any registered middleware.
  3. bypassEvent - If true, an AfterUpdate event will not be emitted.
  4. bypassAll - If true, bypass all three of the effects above.
store.setState(newState, {
  bypassRender: true,
  bypassMiddleware: true,
  bypassEvent: true,
});

Which is the same as:

store.setState(newState, {
  bypassAll: true,
});
// OR
store.initState(newState);

Most Common Store Methods

| Method | Description | | ------------------- | ---------------------------------------------------------- | | nextState() | Return a promise that resolves after the next state change | | reset() | Reset store to its original condition and original state | | use(...middlewares) | Register one or more middlewares |

Other Store Methods

| Method | Description | | -------------------- | ---------------------------------------------------------------------------------------------------- | | clone(withOverrides) | Create a clone of this store, including plugins but excluding event listeners. Useful for unit tests | | hasInitialized() | True if any component has ever used this store (but may not have returned JSX yet) | | getMountCount() | Get the number of mounted components that us this store with useStoreState() or useStoreSelector() | | on(type, handler) | Register a handler to be called for the given event type (see events docs) | | off(type, handler) | De-register a handler for the given event type | | once(type, handler) | Register a handler to be called ONCE for the given event type | | plugin(initializer) | Register a plugin | | getPlugins() | Get the list of initializer functions registered as plugins |

State Getters

Generally you'll want to avoid manually reading the current state. And components should ALWAYS access state using useStoreState/useStoreState.

If you do need to directly set state, you have 4 choices:

| Get | Whole state | State at path | | ------------- | ----------------- | ----------------------- | | Current State | getState() | getStateAt(path) | | Initial State | getInitialState() | getInitialStateAt(path) |

Example:

const store = new Store(21);

// ✅ preferred
store.setState(old => old * 2);

// ❌ discouraged but still works in most cases
store.setState(store.getState() * 2);

Components should normally access state only through one of two hooks:

  1. useStoreSelector(selector) - Select part of or a computed part of the state, re-rendering any time that portion changes.
  2. useStoreState(store) - Select the whole state, re-rendering any time any part of the state changes.

And you'll also notice that all the examples in this README do not actually export the store at all; your feature can export hooks that call useStoreState() or useStoreSelector() internally.

Best Practices

Code splitting

A store can be global or used by a number of components. Regardless, each component must import the store; that way, any components loaded from React.lazy will allow automatic code splitting.

A global store can be extended at any time using store.initState() or store.initStateAt() so a global store can be defined in one file and only extended when needed by another feature.

Suggested File Structure

For global or shared stores, e.g. a theme store:

  • src/stores/theme/themeStore.js
  • src/stores/theme/themeStore.spec.js

For reusable components or pages with private stores, e.g. a header:

  • src/components/Header/Header.jsx
  • src/components/Header/Header.spec.jsx
  • src/components/Header/headerStore.js
  • src/components/Header/headerStore.spec.js

Testing stores

Stores can be easily unit tested inside or outside a React Component.

Unit Test Examples

import myStore, { addToCart } from './myStore';

describe('myStore', () => {
  it('should add to cart with addToCart(item)', async () => {
    myStore.setState({ cart: [], total: 0 });
    addToCart({
      id: 101,
      name: 'White Shoe',
      cost: 123,
    });
    const next = await myStore.nextState();
    expect(next).toEqual({
      cart: [
        {
          id: 101,
          name: 'White Shoe',
          cost: 123,
        },
      ],
      total: 123,
    });
  });
});

Extending Store Behavior

Events

Stores fire a series of lifecycle events. For example:

store.on('BeforeInitialize', () => {
  // The following adds values to the store but uses bypassAll to avoid re-render
  store.mergeState({ my: 'external', initial: 'state' }, { bypassAll: true });
});
store.on('AfterLastUnmount', evt => {
  // cancel side effects such as http requests
  cancelPendingStuff();
});

List of events

The following events fire during the life cycle of the store.

| Event | Description | | ---------------- | ------------------------------------------------------------------- | | BeforeInitialize | Allows changing initial state before first component sees the state | | AfterInitialize | Fires after the first component sees the state | | BeforeFirstUse | Fires after initialization but before being used for the first time | | AfterFirstUse | Fires after store has been used by the first time | | AfterFirstMount | Fires after first component mounts | | AfterMount | Fires after each component mounts | | AfterUnmount | Fires after each component unmounts | | AfterUpdate | Fires after each update to state | | AfterLastUnmount | Fires when last component unmounts | | SetterRejection | Fires if a setter function throws an exception |

Event data

Note that all events have a data property containing the following values.

| Event | event.data value | | ---------------- | ---------------------------------------------------------- | | BeforeInitialize | The initial state (used by plugins to load persisted data) | | AfterInitialize | The state value after initialization | | BeforeFirstUse | The state value | | AfterFirstUse | The state value | | AfterMount | The number of components currently mounted | | AfterUnmount | The number of components currently mounted | | AfterUpdate | { prev: previous state, next: new state } | | SetterRejection | The Error object |

Plugins

The suite of events above allows powerful behavior using plugins. There are 5 included plugins:

  1. consoleLogger - Logs state changes to the console
  2. observable - Adds a subscribe() function to observe the store as an Observable Subject
  3. persistState - Persists state to localStorage or sessionStorage
  4. syncUrl - Persists state to URL using the history API
  5. undo - Adds undo and redo capability to the store

See examples of using these plugins.

Interested in writing your own plugins? Check out how to write plugins.

Middleware

React Thermals has a simple middleware system that allows modifying state before the store is updated and before components rerender.

Middleware examples:

// Example: observe the state but do not alter
myStore.use((context, done) => {
  context.prev; // the old state value
  context.next; // the new state value - alter to modify state
  logToServer(context.next);
  done(); // call done to trigger the next middleware
});

// Example: alter the state
myStore.use((context, done) => {
  context.next = doSomeModifications(context.next);
  done();
});

// Example: alter the state asynchronously
myStore.use((context, done) => {
  doSomeAsyncModifications(context).then(done);
});

Community

Contributing

Contributions welcome! Please see our Contributor Covenant Code of Conduct.

ISC License

View here

Credits

Inspired by @jhonnymichel's react-hookstore

Why did we start at version 4? React Thermals is an evolution of react-create-use-store version 3.