Skip to content

Guides

Folder structure

There are no rules on how to structure stores, but working with exome for couple of years now across multiple very large projects I can make some suggestions:

  1. Try to make 1 file per store;
  2. Try to name store files ending with *.store.ts, *.store.js;
user.store.ts
user-list.store.ts
  1. Try to name stores ending with *Store;
    • It's easy to confuse store names with component names later.
class UserStore extends Exome {}
class UserListStore extends Exome {}
  1. Try to base all logic inside actions instead of components;
    • It's way easier to manage data flow via actions while UI frameworks only display data, UI and trigger user events.
  2. If you're going with features folder structure, then put store there too:
my-project/
├── features/
│   └── jsonlogic-editor/
│       ├── jsonlogic-editor.component.tsx
│       └── jsonlogic-editor.store.ts
│   ...
  1. If you're have different folder structure, then put stores together:
my-project/
├── components/
│   ...
├── stores/
│   ├── settings.store.ts
│   └── user.store.ts
│   ...

Now you may not follow these suggestions and maybe find some better way to handle things, might as well open issue revealing that info with everyone.

Nested stores

Exome is made to handle deeply nested stores with ease.

Before exome there is usually one single store, that contains everything and any mutation to it, requires data to be reducer-drilled. But not in exome.

Think of exome as atomic store. You can have as many stores as needed that each work just for themselves.

To help visualize this, lets create a small family tree store with deeply nested stores.

First create member store that will contain info about one specific member of the tree:

member.store.ts" filename="member.store.ts
import { Exome } from "exome"
 
export class MemberStore extends Exome {
  constructor(
    public firstName: string,
    public lastName: string,
    public partner?: MemberStore,
    public children: MemberStore[] = [],
  ) {
    super()
  }
 
  public setPartner(partner: MemberStore["partner"]) {
    this.partner = partner
 
    // Set both as members as partners at the same time
    if (partner && partner.partner !== this) {
      partner.setPartner(this)
    }
  }
 
  public addChildren(...children: MemberStore["children"]) {
    this.children.push(...children)
  }
}

This store includes some info about member, like first and last names. And some of the information is actually instances of the same store because if for example members "Catelyn Stark" and "Eddard Stark" are partners. And one of the children they have is "Robb Stark".

Create an instance for this using MemberStore.

app.ts
import { MemberStore } from "./member.store.ts"
 
const robbStark = new MemberStore("Robb", "Stark")
const catelynStark = new MemberStore("Catelyn", "Stark")
const eddardStark = new MemberStore("Eddard", "Stark")
 
catelynStark.setPartner(eddardStark)
 
catelynStark.addChildren(robbStark)
eddardStark.addChildren(robbStark)

Now all of the store instances are intertwined (there's even a cyclic reference of partners) and that's ok for Exome.

We just need a unified store to make this tree complete.

tree.store.ts" filename="tree.store.ts
import { Exome } from "exome"
import { MemberStore } from "./member.store.ts"
 
export class TreeStore extends Exome {
  constructor(
    public members: MemberStore[],
  ) {
    super()
  }
}
app.ts
import { MemberStore } from "./member.store.ts"
import { TreeStore } from "./tree.store.ts"
 
const robbStark = new MemberStore("Robb", "Stark")
const catelynStark = new MemberStore("Catelyn", "Stark")
const eddardStark = new MemberStore("Eddard", "Stark")
 
catelynStark.setPartner(eddardStark)
 
catelynStark.addChildren(robbStark)
eddardStark.addChildren(robbStark)
 
const starkFamilyTree = new TreeStore([ 
  catelynStark, 
  eddardStark, 
]) 

Now this is what our store instance startFamilyTree looks like:

TreeStore {
  members: [
    MemberStore {
      children: [
        MemberStore {
          children: [],
          firstName: "Robb",
          lastName: "Stark",
        },
      ],
      firstName: "Catelyn",
      lastName: "Stark",
      partner: MemberStore {
        children: [
          MemberStore {
            children: [],
            firstName: "Robb",
            lastName: "Stark",
          },
        ],
        firstName: "Eddard",
        lastName: "Stark",
        partner: MemberStore [circular],
      },
    },
    MemberStore {
      children: [
        MemberStore {
          children: [],
          firstName: "Robb",
          lastName: "Stark",
        },
      ],
      firstName: "Eddard",
      lastName: "Stark",
      partner: MemberStore {
        children: [
          MemberStore {
            children: [],
            firstName: "Robb",
            lastName: "Stark",
          },
        ],
        firstName: "Catelyn",
        lastName: "Stark",
        partner: MemberStore [circular],
      },
    },
  ],
}

You can see [circular] there and another thing to note is that "Robb Stark" is shared across multiple members. Each store here can act on themselves.

For example if "Robb Stark" finds a partner we can just call action on that and store is updated and only related store instances will be updated.

app.ts
const talisaStark = new MemberStore("Talisa", "Stark")
 
talisaStark.setPartner(robbStark)

Testing

As for testing goes there are 2 things that can go wrong here:

Snapshots

Usually running tests with snapshots, we get some kind of string representation of object we're testing. For example in jest, snapshot testing HTMLElement turns them into something similar to html tree, Objects into stringified json etc.

Exome store each have an id as symbol. Depending on configuration and environment, it may be displayed in snapshots.

Other thing that might go wrong in snapshots is testing cyclic stores as for example jest will throw error instead of making a snapshot.

For stores to work you can use exome/jest snapshot serializer. This works in jest and vitest. For other snapshot serializer implementations feel free to send in a PR.

For jest setup:

jest.config.json
{
  "snapshotSerializers": [
    "./node_modules/exome/jest/serializer.js"
  ]
}

For vitest currently it's a bit more of a setup:

vitest.setup.ts
import * as exomeSnapshotSerializer from "exome/jest/serializer.js"
 
expect.addSnapshotSerializer({
  serialize(val, config, indentation, depth, refs, printer) {
    return exomeSnapshotSerializer.print(val, (val) =>
      printer(val, config, indentation, depth, refs)
    );
  },
  test: exomeSnapshotSerializer.test,
})

And that's it, now you should be covered with presenting store name, showing circular stores and getting rid of exome id symbol. It should look something like this:

TodoListStore {
  todos: [
    TodoStore {
      "content": "Take out thrash",
      "completed": false,
    },
  ],
}

Mocking store

Generally it's not advised to mock store, but if you really need it, it can be done using GhostExome.

counter.store.test.ts
import { GhostExome } from "exome/ghost"
import { CounterStore } from "./counter.store.ts"
 
jest.mock("./counter.store.ts", () => ({
  CounterStore: class extends GhostExome {
    count: 0,
    increment: jest.fn(),
  },
}))
 
// ...

Note that using it results in it pretending to be store. No change detection will occur on actions defined on this ghost store and no events will be triggered.

Devtools

Exome has a companion browser extension Exome Developer Tools that sits inside browser devtools panel. It waits for connection from any website, but your website must start the connection. This can be used for connecting in dev environment but not in production. Or even connect in dev environment and in production define some kind of dev mode for your user and then connect to devtools for debugging in production.

Devtools contain detailed information about every store instance that is created and actions that are being triggered.

Use unstableExomeDevtools to connect your app to devtools

Example
import { addMiddleware } from "exome"
import { unstableExomeDevtools } from "exome/devtools"
 
addMiddleware(
  unstableExomeDevtools({
    name: "App name",
  })
)

Custom integration

Every integration currently is based on subscribe function. For example this is react integration (it's a bit reduced for this example):

exome/react
import { type Exome, subscribe } from "exome"
import { useLayoutEffect, useState } from "react"
 
export function useStore<T extends Exome>(store: T): Readonly<T> {
  const [, render] = useState(0)
 
  useLayoutEffect(
    () => subscribe(store, () => render((n) => n + 1)),
    [store],
  )
 
  return store
}

This is basically it, there's a subscribe, that takes in store instance and then updates component whenever change event is fired. After component unmounts subscription is canceled (unsubscribed).

Same recipe can be used for any other integration. Lets create some custom integration for our vanilla js counter app:

index.ts
import { subscribe } from "exome"
import { CounterStore, counterStore } from "./counter.store"
 
document.body.innerHTML = `
  <button id="count"></button>
`
const counterButton = document.getElementById("count")
counterButton.addEventListener("click", counterStore.increment)
 
const unsubscribe = subscribe(counterStore, update) 
 
function update(instance: CounterStore) { 
  counterButton.innerText = instance.count 
} 
 
// Run initial update if needed since update
// doesn't do it when first subscribed
update(counterStore)
Live preview

For other type of integrations that cannot be covered only by update event you can use addMiddleware and onAction functions.