Important: This documentation covers Yarn 1 (Classic).
For Yarn 2+ docs and migration guide, see yarnpkg.com.

Package detail

exome

Marcisbee2.1kMIT2.8.1TypeScript support: included

State manager for deeply nested states

store, state, state-manager, deep, nested, react, preact, vue, lit, rxjs, svelte

readme

CI npm jsr package size discord

State manager for deeply nested states. Includes integration for React, Preact, Vue, Svelte, Solid, Lit, Rxjs, Angular & No framework. Can be easily used in microfrontends architecture.

Features

  • 📦 Small: Just 1 KB minizipped
  • 🚀 Fast: Uses no diffing of state changes see benchmarks
  • 😍 Simple: Uses classes as state, methods as actions
  • 🛡 Typed: Written in strict TypeScript
  • 🔭 Devtools: Redux devtools integration
  • 💨 Zero dependencies
// store/counter.ts
import { Exome } from "exome"

export class Counter extends Exome {
  public count = 0

  public increment() {
    this.count += 1
  }
}

export const counter = new Counter()
// components/counter.tsx
import { useStore } from "exome/react"
import { counter } from "../stores/counter.ts"

export default function App() {
  const { count, increment } = useStore(counter)

  return (
    <h1 onClick={increment}>{count}</h1>
  )
}

Simple Demo

Table of contents

Installation

To install the stable version:

npm install --save exome

This assumes you are using npm as your package manager.

Core concepts

Any piece of state you have, must use a class that extends Exome.

Stores

Store can be a single class or multiple ones. I'd suggest keeping stores small, in terms of property sizes.

State values

Remember that this is quite a regular class (with some behind the scenes logic). So you can write you data inside properties however you'd like. Properties can be public, private, object, arrays, getters, setters, static etc.

Actions

Every method in class is considered as an action. They are only for changing state. Whenever any method is called in Exome it triggers update to middleware and updates view components. Actions can be regular methods or even async ones.

If you want to get something from state via method, use getters.

Usage

Library can be used without typescript, but I mostly recommend using it with typescript as it will guide you through what can and cannot be done as there are no checks without it and can lead to quite nasty bugs.

To create a typed store just create new class with a name of your choosing by extending Exome class exported from exome library.

import { Exome } from "exome"

// We'll have a store called "CounterStore"
class CounterStore extends Exome {
  // Lets set up one property "count" with default value "0"
  public count = 0

  // Now lets create action that will update "count" value
  public increment() {
    this.count += 1
  }
}

Open in dune.land

That is the basic structure of simple store. It can have as many properties as you'd like. There are no restrictions.

Now we should create an instance of CounterStore to use it.

const counter = new CounterStore()

Nice! Now we can start using counter state.

Integration

React

Use useStore() from exome/react to get store value and re-render component on store change.

import { useStore } from "exome/react"
import { counter } from "../stores/counter.ts"

export function Example() {
  const { count, increment } = useStore(counter)
  return <button onClick={increment}>{count}</button>
}

Preact

Use useStore() from exome/preact to get store value and re-render component on store change.

import { useStore } from "exome/preact"
import { counter } from "../stores/counter.ts"

export function Example() {
  const { count, increment } = useStore(counter)
  return <button onClick={increment}>{count}</button>
}

Vue

Use useStore() from exome/vue to get store value and re-render component on store change.

<script lang="ts" setup>
  import { useStore } from "exome/vue";
  import { counter } from "./store/counter.ts";

  const { count, increment } = useStore(counter);
</script>

<template>
  <button @click="increment()">{{ count }}</button>
</template>

Svelte

Use useStore() from exome/svelte to get store value and re-render component on store change.

<script>
  import { useStore } from "exome/svelte"
  import { counter } from "./store/counter.js"

  const { increment } = counter
  const count = useStore(counter, s => s.count)
</script>

<main>
  <button on:click={increment}>{$count}</button>
</main>

Solid

Use useStore() from exome/solid to get store value and update signal selector on store change.

import { useStore } from "exome/solid"
import { counter } from "../stores/counter.ts"

export function Example() {
  const count = useStore(counter, s => s.count)
  return <button onClick={counter.increment}>{count}</button>
}

Lit

Use StoreController from exome/lit to get store value and re-render component on store change.

import { StoreController } from "exome/lit"
import { counter } from "./store/counter.js"

@customElement("counter")
class extends LitElement {
  private counter = new StoreController(this, counter);

  render() {
    const { count, increment } = this.counter.store;

    return html`
      <h1 @click=${increment}>${count}</h1>
    `;
  }
}

Rxjs

Use observableFromExome from exome/rxjs to get store value as Observable and trigger it when it changes.

import { observableFromExome } from "exome/rxjs"
import { counter } from "./store/counter.js"

observableFromExome(countStore)
  .pipe(
    map(({ count }) => count),
    distinctUntilChanged()
  )
  .subscribe((value) => {
    console.log("Count changed to", value);
  });

setInterval(counter.increment, 1000);

Angular

signals (>=16)

Use useStore from exome/angular to get store value and update signal selector on store change.

import { useStore } from "exome/angular"
import { counter } from "./store/counter.ts"

@Component({
  selector: 'my-app',
  template: `
    <h1 (click)="increment()">
      {{count}}
    </h1>
  `,
})
export class App {
  public count = useStore(counter, (s) => s.count);
  public increment() {
    counter.increment();
  }
}

observables (<=15)

Angular support is handled via rxjs async pipes!

Use observableFromExome from exome/rxjs to get store value as Observable and trigger it when it changes.

import { observableFromExome } from "exome/rxjs"
import { counter } from "./store/counter.ts"

@Component({
  selector: 'my-app',
  template: `
    <h1 *ngIf="(counter$ | async) as counter" (click)="counter.increment()">
      {{counter.count}}
    </h1>
  `,
})
export class App {
  public counter$ = observableFromExome(counter)
}

No framework

Use subscribe from exome to get store value in subscription callback event when it changes.

import { subscribe } from "exome"
import { counter } from "./store/counter.js"

const unsubscribe = subscribe(counter, ({ count }) => {
  console.log("Count changed to", count)
})

setInterval(counter.increment, 1000)
setTimeout(unsubscribe, 5000)

Redux devtools

You can use redux devtools extension to explore Exome store chunk by chunk.

Just add exomeReduxDevtools middleware via addMiddleware function exported by library before you start defining store.

import { addMiddleware } from 'exome'
import { exomeReduxDevtools } from 'exome/devtools'

addMiddleware(
  exomeReduxDevtools({
    name: 'Exome Playground'
  })
)

It all will look something like this:

Exome using Redux Devtools

API

Exome

A class with underlying logic that handles state changes. Every store must be extended from this class.

abstract class Exome {}

useStore

Is function exported from "exome/react".

function useStore<T extends Exome>(store: T): Readonly<T>

Arguments

  1. store (Exome): State to watch changes from. Without Exome being passed in this function, react component will not be updated when particular Exome updates.

Returns

  • Exome: Same store is returned.

Example

import { useStore } from "exome/react"

const counter = new Counter()

function App() {
  const { count, increment } = useStore(counter)

  return <button onClick={increment}>{count}</button>
}

Open in dune.land

onAction

Function that calls callback whenever specific action on Exome is called.

function onAction(store: typeof Exome): Unsubscribe

Arguments

  1. store (Exome constructor): Store that has desired action to listen to.
  2. action (string): method (action) name on store instance.
  3. callback (Function): Callback that will be triggered before or after action.
    Arguments
    • instance (Exome): Instance where action is taking place.
    • action (String): Action name.
    • payload (any[]): Array of arguments passed in action.
  4. type ("before" | "after"): when to run callback - before or after action, default is "after".

Returns

  • Function: Unsubscribes this action listener

Example

import { onAction } from "exome"

const unsubscribe = onAction(
  Person,
  'rename',
  (instance, action, payload) => {
    console.log(`Person ${instance} was renamed to ${payload[0]}`);

    // Unsubscribe is no longer needed
    unsubscribe();
  },
  'before'
)

saveState

Function that saves snapshot of current state for any Exome and returns string.

function saveState(store: Exome): string

Arguments

  1. store (Exome): State to save state from (will save full state tree with nested Exomes).

Returns

  • String: Stringified Exome instance

Example

import { saveState } from "exome/state"

const saved = saveState(counter)

loadState

Function that loads saved state in any Exome instance.

function loadState(
  store: Exome,
  state: string
): Record<string, any>

Arguments

  1. store (Exome): Store to load saved state to.
  2. state (String): Saved state string from saveState output.

Returns

  • Object: Data that is loaded into state, but without Exome instance (if for any reason you have to have this data).

Example

import { loadState, registerLoadable } from "exome/state"

registerLoadable({
  Counter
})

const newCounter = new Counter()

const loaded = loadState(newCounter, saved)
loaded.count // e.g. = 15
loaded.increment // undefined

newCounter.count // new counter instance has all of the state applied so also = 15
newCounter.increment // [Function]

registerLoadable

Function that registers Exomes that can be loaded from saved state via loadState.

function registerLoadable(
  config: Record<string, typeof Exome>,
): void

Arguments

  1. config (Object): Saved state string from saveState output.
    • key (String): Name of the Exome state class (e.g. "Counter").
    • value (Exome constructor): Class of named Exome (e.g. Counter).

Returns

  • void

Example

import { loadState, registerLoadable } from "exome/state"

registerLoadable({
  Counter,
  SampleStore
})

addMiddleware

Function that adds middleware to Exome. It takes in callback that will be called every time before an action is called.

React hook integration is actually a middleware.

type Middleware = (instance: Exome, action: string, payload: any[]) => (void | Function)

function addMiddleware(fn: Middleware): void

Arguments

  1. callback (Function): Callback that will be triggered BEFORE action is started.
    Arguments

    • instance (Exome): Instance where action is taking place.
    • action (String): Action name.
    • payload (any[]): Array of arguments passed in action.

    Returns

    • (void | Function): Callback can return function that will be called AFTER action is completed.

Returns

  • void: Nothingness...

Example

import { Exome, addMiddleware } from "exome"

addMiddleware((instance, name, payload) => {
  if (!(instance instanceof Timer)) {
    return;
  }

  console.log(`before action "${name}"`, instance.time);

  return () => {
    console.log(`after action "${name}"`, instance.time);
  };
});

class Timer extends Exome {
  public time = 0;

  public increment() {
    this.time += 1;
  }
}

const timer = new Timer()

setInterval(timer.increment, 1000)

// > before action "increment", 0
// > after action "increment", 1
//   ... after 1s
// > before action "increment", 1
// > after action "increment", 2
//   ...

Open in Codesandbox

FAQ

Q: Can I use Exome inside Exome?

YES! It was designed for that exact purpose. Exome can have deeply nested Exomes inside itself. And whenever new Exome is used in child component, it has to be wrapped in useStore hook and that's the only rule.

For example:

class Todo extends Exome {
  constructor(public message: string, public completed = false) {
    super();
  }

  public toggle() {
    this.completed = !this.completed;
  }
}

class Store extends Exome {
  constructor(public list: Todo[]) {
    super();
  }
}

const store = new Store([
  new Todo("Code a new state library", true),
  new Todo("Write documentation")
]);

function TodoView({ todo }: { todo: Todo }) {
  const { message, completed, toggle } = useStore(todo);

  return (
    <li>
      <strong
        style={{
          textDecoration: completed ? "line-through" : "initial"
        }}
      >
        {message}
      </strong>
      &nbsp;
      <button onClick={toggle}>toggle</button>
    </li>
  );
}

function App() {
  const { list } = useStore(store);

  return (
    <ul>
      {list.map((todo) => (
        <TodoView key={getExomeId(todo)} todo={todo} />
      ))}
    </ul>
  );
}

Open in dune.land

Q: Can deep state structure be saved to string and then loaded back as an instance?

YES! This was also one of key requirements for this. We can save full state from any Exome with saveState, save it to file or database and the load that string up onto Exome instance with loadState.

For example:

const savedState = saveState(store)

const newStore = new Store()

loadState(newStore, savedState)

Q: Can I update state outside of React component?

Absolutely. You can even share store across multiple React instances (or if we're looking into future - across multiple frameworks).

For example:

class Timer extends Exome {
  public time = 0

  public increment() {
    this.time += 1
  }
}

const timer = new Timer()

setInterval(timer.increment, 1000)

function App() {
  const { time } = useStore(timer)

  return <h1>{time}</h1>
}

Open in Codesandbox

IE support

To run Exome on IE, you must have Symbol and Promise polyfills and down-transpile to ES5 as usual. And that's it!

Motivation

I stumbled upon a need to store deeply nested store and manage chunks of them individually and regular flux selector/action architecture just didn't make much sense anymore. So I started to prototype what would ideal deeply nested store interaction look like and I saw that we could simply use classes for this.

Goals I set for this project:

  • <input checked="" disabled="" type="checkbox"> Easy usage with deeply nested state chunks (array in array)
  • <input checked="" disabled="" type="checkbox"> Type safe with TypeScript
  • <input checked="" disabled="" type="checkbox"> To have actions be only way of editing state
  • <input checked="" disabled="" type="checkbox"> To have effects trigger extra actions
  • <input checked="" disabled="" type="checkbox"> Redux devtool support

License

MIT © Marcis Bergmanis

changelog

Changelog

2.8.1

Bugfix

  • Detect thenable (not necessarily a Promise instance) in actions.

2.8.0

Feature

  • Allow to read action response/return value from getActionStatus function.

2.7.0

Feature

  • Improve types for onAction function.

2.6.2

Other

  • Allow null and undefined in useStore function.

2.6.1

Other

  • Updates typedoc.

2.6.0

Feature

  • Adds action response data to addMiddleware, runMiddleware and onAction.

2.5.0

Feature

  • Allows deep inheritance.

2.4.4

Other

  • Upgrades esbuild.
  • Upgrades biome.

2.4.3

Other

  • Makes types more explicit.
  • Publish to jsr.

2.4.2

Other

  • Pass action error to devtools.

2.4.1

Bugfix

  • Properly re-throw errors from async actions.

2.4.0

Feature

  • Adds getActionStatus to exome/utils.

2.3.3

Bugfix

  • Properly handle errors in actions.
  • Middleware now also returns error from actions.

2.3.2

Bugfix

  • Adds version export to unstableExomeDevtools.

2.3.1

Other

  • Adds unstableExomeDevtools method to exome/devtools package.

2.3.0

Feature

  • Add LOAD_STATE middleware action after state was prefilled via loadState.

2.2.0

Feature

  • Increase exome id length by 2 characters to lessen the birthday paradox.
  • Use "try finally" for running "NEW" actions instead of "Promise.resolve()".

2.1.0

Feature

  • Adds support for Solid.js.
  • Adds support for Angular signals.

2.0.4

Bugfix

  • Print circular store references in jest snapshots.

2.0.3

Bugfix

  • Fixes jest snapshot serializer depth.

2.0.2

Bugfix

  • Fixes subscribe method where it did not send store instance as argument.

2.0.1

Bugfix

  • Fixes vue integration of useStore.

2.0.0

Breaking

  • Reorganizes imports;
  • Removes updateMap;
  • Replaces updateView with updateAll;
  • Replaces exomeDevtools with exomeReduxDevtools.

Please read the migration guide to ease the upgrade process.

Migration guide

v2 includes some breaking changes around subscriptions. It better reorganizes files and imports.

Here are changes that need to be made:

  1. subscribe is no longer in a separate import:
-import { subscribe } from "exome/subscribe";
+import { subscribe } from "exome";
  1. saveState, loadState and registerLoadable is no longer part of root import:
-import { saveState, loadState, registerLoadable } from "exome";
+import { saveState, loadState, registerLoadable } from "exome/state";
  1. GhostExome is no longer part of root import:
-import { GhostExome } from "exome";
+import { GhostExome } from "exome/ghost";
  1. updateMap is no longer exposed (use subscribe, update and updateAll to listen to changes or trigger them):

  2. updateView is renamed to updateAll:

-import { updateView } from "exome";
+import { updateAll } from "exome";
  1. exomeDevtools is renamed to exomeReduxDevtools:
-import { exomeDevtools } from "exome/devtools";
+import { exomeReduxDevtools } from "exome/devtools";

1.5.6

Other

  • Published to npm with new logo.

1.5.5

Other

  • Published to npm with provenance.

1.5.4

Bugfix

  • Removes peerDependencies from package.json.

1.5.3

Bugfixes

  • Updates documentation;
  • Cleans up published package.json file.

1.5.0

Features

  • Moves exported import files to .mjs file format.

1.4.0

Features

  • Adds support for Svelte.

1.3.0

Features

  • Performance improvements;
  • Gets rid of Proxy usage as it was just an overhead without real benefits.

1.2.0

Features

  • Adds RXJS Observable support.

1.1.0

Features

  • Adds Deno support.

1.0.3

Bugfixes

  • Fixes broken redux devtools url.

1.0.2

Bugfixes

  • Fixes issue where getter get called before ready.

1.0.1

Bugfixes

  • Fixes rxjs compatibility issue when using BehaviorSubject inside Exome.

1.0.0

Stable release

No actual changes as it's proven to be stable fo v1.

0.16.0

Feature

  • Adds lit support.

Added new ReactiveController named StoreController as part of lit v2.0.

0.15.0

Feature

  • Arrow functions no longer trigger actions.

This was previously wrong as we only should trigger actions for prototype methods. It is useful to define arrow method to GET some data and that should NOT trigger action and re-render.

0.14.0

Feature

  • Adds experimental afterLoadState method that triggers callback whenever Exome data was loaded via loadState.

0.13.0

Feature

  • Adds new onAction method that triggers callback whenever specific action is called.

0.12.4

Bugfixes

  • Fixes loadState inability to load circular Exome instances.

0.12.3

Bugfixes

  • Fixes saveState snapshot of circular Exome instances.

0.12.1

Bugfixes

  • saveState and loadState now works with minified class names;
  • Issue with state type but warning about store type in load-state (#8).

0.12.0

Breaking changes

  • Adds registerLoadable method that gathers all available Exomes that can be registered;
  • Removes 3rd argument for loadState method;
- loadState(target, state, { Person, Dog })
+ registerLoadable({ Person, Dog })
+ loadState(target, state)

0.11.0

  • Adds subscribe method that allows to listen for changes in particular Exome instance;

0.10.1

  • Fixes jest serializer output for GhostExome;

0.10.0

  • Adds GhostExome class;

    It is accepted as Exome instance, but will not update or call middleware.

0.9.2

  • Fixes type declaration.

0.9.1

  • Fixes vue export;
  • Adds setExomeId method.

0.9.0

  • Adds Vue support.

    Added useStore hook for Vue 3 composition api.