Skip to main content

Built and signed on GitHub Actions

Atomic state management library for React

This package works with Bun, Cloudflare Workers, Node.js, Deno, Browsers
JSR Score
100%
Published
2 weeks ago (0.1.24)

What is Charm

Charm is an atomic state management library for React.

Latest documentation is available at https://jsr.io/@kaigorod/charm/doc


// counterCharm.ts 

const counterCharm = charm(0);

export const useCounter = () => useCharm(counterCharm);
export const inc = counterCharm.update(prev => prev + 1);

// Counter.tsx

const Counter = () => {
  const counter = useCounter();

  return <button onclick={inc}>
    clicked {counter} times
  </button>
}

State management best practices

Atoms stay protected

Notice that counterCharm is not exported directly. This way we avoid un-expected direct modifications of the atom we created.

Instead, we create and expose micro-API to interact with the particle.

useCounter

useCounter() is extremly simple to use, it does two things.

First, it returns the value of the current value of the counter.

Second, as any React hook, it subscribes the component to changes of the counter. So, when a counter changes then the components re-renders.

Avoid setter callback hooks

Notice, where is no setCounter function returned by the useCharm hook. One of the distiguishing feature of Charm is that your Component doesn't have to subscribe to the changes of the hooks.

You can update Charm value from simple functions. You don't need to call hook to create function on every render.

There several benefit for this:

  • component doesn't have to re-render if they use just the update callbacks
  • with less re-renders the app is getting better performance
  • you don't have to write useCallback, useMemo and other cache code for you update callbacks.
  • you avoid having complicate cached callback bugs with useEffect and event listeners.
  • you write less code, and your code is more readable

So, this way the only case your components got re-render is the actual change of the atom they implicitly subscribed to using the use-Charm hook.

How update callbacks work internally

Modern state management libaries, in addition to managing front-end state have to provide compatibility with Server-Side Rendering (SSR) and Server-Side Generation (SSG) rendering. In this mode, the page of the application are rendered in-parallel. This way every atom sits in the memory of the node application in multiple instances, one per every page being rendered. A specific state of all the atom and variables which is required to render a single page call Store.

Let's look at the example of a todo app which uses server-side rendering. When users request of the app simultateously request their todo-list page at the same time then their todo-list pages render separately and in parallel. One page and one store for every single user request. For different user the page has different data to show. But, the nodejs application has single memory space where it creates atoms, functions and other variables. Store

To distinguish page renders tools like redux, recoil, jotai use ReactContext-based approach. They use ReactContext providers at the root of the render tree and then use ReactContext on the leaves of the render tree. This why are force to create callback using hooks in order to pass the rendering context to the hooks.

Unlike other libs, Charm is using AsyncLocalStorage to pass rendering context to the callbacks and other functions. This way Charm stay independant from React context and does not require developers to write hooks for callbacks.

Best practices, enabled

Don't Repeat Yourself

Data consistancy

When you don't expose raw set-state API to world your atoms transition from one meanigful to another meaningful state. There is not transional or partially correct state of the atoms.

No orbitrary code is able to modify the state of your atoms.

When you refactor you code or fixing a bug then you are certain you have only single place to make change to or review.

Composing interactions

Quite often you need to perform multiple state changes per single use action. For example, when a user creates a new slide then they expect that:

  • (1) a new slide appears in the list of the slides
  • (2) and new slide becomes selected in the list of the slides

The code to perform these two actions together is actuall, a very simple and familiar function composition:

// file: slide-commands.ts

import { createNewEmptyAfterCurrentSlide } from "@state/slideList"
import { switchToNextSlide } from "@state/slideList"


const createNewSlideAfterCurrentSlide = () => {
  createNewEmptyAfterCurrentSlide();
  switchToNextSlide();
}

Self-documenting functions

A javascript function declaration is a great descriptor of the action it performs. And it is obvious and familiar documentation of how to use it.

When you declare your action as functions you provide

  • descriptive function name
  • descriptive list of parameters
  • descriptive type of parameters
  • optional, js-doc.

You loose these clear descriptions when you are forced to:

  • create lists of string of the name of the actions
  • implement switch/case clauses
  • have "reducer" code
  • wrap actions into hooks code
  • wrap actions into useMemo, useCallback code

Manageable modifications

Application layers

For larger project, mc recommends to split state operation and user actions code into two separate layers.

State actions layer

The state actions are simple javascript functions on top of the state particles.

The state actions code knows nothing about user actions, DOM, UI, HTML and Components.

This layer contains all the business logic of application and it stays easy-to-test.

User interface commands

User interface commands are build on top of state actions.

User interface commands know all about the hovers, mousedowns, clicks, drags, keypresses, onChanges, of your application and make it smooth for users.

This way, all the UI-complications don't get mixed up with the business logic.

For even larger projects it might make sense to move state actions of a specific business domain to a dedicated workspace package.

Derived particles


// word.ts 

const wordCharm = charm("compatibility");
const lettersCharm = derivedCharm(wordCharm, (word) => word.length, NeverSet)

export const useWord = useCharm(wordCharm);
export const setWord = charmSetter(wordCharm)

export const useLetters = useCharm(lettersCharm);


// Counter.tsx

const WordAndLetters = () => {
  const word = useWord();
  const letters = useLetters();

  return <>
    <p>
      <input onChange={e => setWord(e.target.value)}/>
    </p>
    <p>
      Word "{word}" contains {letters} letters.
    </p>
  </>    
}

Split array atoms

import { expect, mock, test } from "bun:test";
import { splitCharm } from "../src/arrays";
import { charm, type Charm } from "../src/charm";

test("charmList does not change when single value changes", () => {
  const arrayCharm = charm([10, 20]);
  const splitArrayCharm = splitCharm(arrayCharm);

  const charm0: Charm<number> = splitArrayCharm.get()[0];
  const charm1: Charm<number> = splitArrayCharm.get()[1];

  const nopFn = () => { }
  const subCharmMock = mock(nopFn as any)
  const sub0 = mock(nopFn as any)
  const sub1 = mock(nopFn as any)

  /// test


  splitArrayCharm.sub(subCharmMock)
  charm0.sub(sub0);
  charm1.sub(sub1);

  charm0.set(0);

  expect(sub0).toHaveBeenCalledTimes(1);

  expect(sub1).toHaveBeenCalledTimes(0);

  expect(subCharmMock).toHaveBeenCalledTimes(0);
});

Using Provider for SSR


const aCharm = charm(1);

const Comp = ({}) => {
  const value = useCharm(aCharm)
  return <button>
    {value}
  </button>
}


test("render two pages at the same time", () => {
  const page1 = execWithCharm(createCharmStore(), () => {
    const comp = <Comp />
    aCharm.set(10);
    return renderToString(comp)
  })
  const page2 = execWithCharm(createCharmStore(), () => {
    const comp = <Comp />
    aCharm.set(20);
    return renderToString(comp)
  })

  expect(page1).toEqual("<button>10</button>")
  expect(page2).toEqual("<button>20</button>")
});

Install


npx jsr add @kaigorod/charm

# or

bunx 

# or

deno add @kaigorod/charm

This command will add the following line to your package.json file

{
  //in package.json, 
  "@kaigorod/charm": "npm:@jsr/kaigorod__charm",
}
import { charm, charmGetter, charmSetter, useCharm } from "@kaigorod/charm";

const isImageSearchOnCharm = charm(false);

export const useIsImageSearchOn = () => useCharm(isImageSearchOnCharm);
export const setIsImageSearchOn = charmSetter(isImageSearchOnCharm);

Inspiration

Charm is inspired by recoil and jotai state management libraries.

Links

Built and signed on
GitHub Actions
View transparency log

Add Package

deno add @kaigorod/charm

Import symbol

import * as mod from "@kaigorod/charm";

Add Package

npx jsr add @kaigorod/charm

Import symbol

import * as mod from "@kaigorod/charm";

Add Package

yarn dlx jsr add @kaigorod/charm

Import symbol

import * as mod from "@kaigorod/charm";

Add Package

pnpm dlx jsr add @kaigorod/charm

Import symbol

import * as mod from "@kaigorod/charm";

Add Package

bunx jsr add @kaigorod/charm

Import symbol

import * as mod from "@kaigorod/charm";