Skip to content

Commit

Permalink
WIP: first checkin of atomicStore
Browse files Browse the repository at this point in the history
  • Loading branch information
beorn committed Nov 21, 2024
1 parent 6a77102 commit d147e95
Show file tree
Hide file tree
Showing 13 changed files with 945 additions and 4 deletions.
155 changes: 155 additions & 0 deletions ATOMIC-STORE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
Here's an updated spec/README — I'm trying out some nomenclature (atomic store, computeds => derived state) to see if it makes it easier/simpler.

It struck me that it is presumably possible to make this atomic store completely Zustand compatible, and it would probably be possible to wrap a Zustand store to make it an atomic store — it wouldn't have derived state, but you could add that if you wanted to.

# Atomic Store

An atomic store is a type inferred central store defined using a `State` object with properties of these types:

- actions that update the state (defined using methods)
- derived state (defined using getters)
- basic state (all other properties)

The store exposes each of the properties as an appropriate Jotai atom which you can then consume/use to interact with the state in the store.

This way you can benefit from both the conciseness and simplicity of a central Zustand-ish store definition syntax, and the Jotai atom benefits such as cached, auto-updating derived values, and direct subscriptions that doesn't require selectors.

## Definition

```tsx
import { createAtomicStore } from 'jotai-zustand'

const atomicStore = createAtomicStore({
a: 1,
b: 2,

// derived state defined using getters
get sum() { return this.a + this.b },
get sum2() { return this.sum * 2 },

// actions return Partial<State> or mutate state directly
adda(n: number) { return { a: this.a + n } },
addb(n: number) { this.b += n },
});
// => {
// a: PrimitiveAtom<number>
// b: PrimitiveAtom<number>
// sum: Atom<number>
// sum2: Atom<number>
// adda: WritableAtom<null, [number], void>
// addb: WritableAtom<null, [number], void>
// };
```

All method properties on the state object are considered to be actions, and they must either mutate the state in the `this` object directly, or return `Partial<State>`, which will then be merged with the existing state.

Derived state (aka computeds or computed values) are defined using getters, and are automatically updated when the state they depend on changes. Be careful not to create circular dependencies.

## Usage

The store can be consumed as a set of atoms:

```tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai'

export default function MyComponent() {
const a = useAtomValue(atomicStore.a) // number
const sum2x = useAtomValue(atomicStore.sum2) // number
const adda = useSetAtom(atomicStore.adda) // (n: number) => void

return (
<div>
<div>a: {a}</div>
<div>sum2x: {sum2x}</div>
<button onClick={() => adda(5)}>Add 5 to a</button>
</div>
)
}
```

Or through `useStore` and selectors, similarly to how Zustand works:

```tsx
import { useStore } from 'jotai-zustand'
const sum = useStore(atomicStore, (state) => state.sum)
const state = useStore(atomicStore)
```

Using selectors is not quite as performant as using atoms. Each `useStore` call in each component instance will register a selector that is called on every store update. This can be expensive if you render many components that use selectors.

Component instances that use atoms has no performance penalty unless the atom they depend on changes value.

## Craz idea: Generalization

The state definition object above could actually connect to and bridge to other state systems, e.g.,

```tsx
import { fromZustand, fromSignal, type State } from 'jotai-zustand'
const store = create({
zustand: fromZustand(zustandStore), // composable
signal: fromSignal(signal$), // maybe auto-detect type
a: 1,
b: 2,
get sum() { return this.zustand.var + this.signal }
})
// => State<{
// zustand: State<...zustandStore>,
// signal: number,
// a: number,
// b: number,
// sum: readonly number
// }>
fromAtomic(store, { // extensible
get sum2() { return this.sum * 2 }
})
// => State<{
// zustand: State<...zustandStore>,
// signal: number,
// a: number,
// b: number,
// sum: number,
// sum2: number
// }>

toSignals(store)
// => {
// zustand: { var: Signal<number> },
// a: Signal<number>,
// b: Signal<number>,
// signal: Signal<number>,
// sum: Signal<number>
// }
toAtoms(store)
// => {
// zustand: { var: atom<...> },
// signal: atom<number>,
// a: atom<number>,
// b: atom<number>,
// sum: atom<number>
// }
```

## To do

Must explore:

- [ ] Best way to track dependencies and create atoms
- [ ] Naming :)

Also likely explore:

- [ ] Generalization to other state systems
- [ ] Zustand compatibility
- [ ] Consume store using selectors — ideate API (the above Zustand one looks good to me, but not clear how to deal with setting basic state)
- [ ] Also offer a setState / getState API
- [ ] Create atomic store from a Zustand store (and allow easy addition of derived state)
- [ ] Dealing with async (state, computeds, actions, selectors)
- [ ] Allow property setters in addition to property getters

Perhaps out of scope:

- [ ] Dealing with nested stores/state (I think this would be very useful)

Out of scope:

- [ ] Also allow using atoms within the store
2 changes: 1 addition & 1 deletion examples/01_typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "example",
"name": "example-01",
"version": "0.0.0",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion examples/02_create/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "example",
"name": "example-02",
"version": "0.0.0",
"private": true,
"type": "module",
Expand Down
9 changes: 9 additions & 0 deletions examples/03_atomic/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<html>
<head>
<title>example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
22 changes: 22 additions & 0 deletions examples/03_atomic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "example-03",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"jotai": "latest",
"jotai-zustand": "latest",
"react": "latest",
"react-dom": "latest",
"zustand": "latest"
},
"devDependencies": {
"@types/react": "latest",
"@types/react-dom": "latest",
"typescript": "latest",
"vite": "latest"
},
"scripts": {
"dev": "vite"
}
}
40 changes: 40 additions & 0 deletions examples/03_atomic/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createAtomicStore } from '../../../src/index.js';
import { useAtomValue, useSetAtom } from 'jotai/react';

const store = createAtomicStore({
count: 0,
get half() {
return this.count / 2;
},
get dbl() {
console.log('dbl - count=', this.count);
return this.half * 4;
},
inc(n = 1) {
return { count: this.count + n };
},
});

const Counter = () => {
const count = useAtomValue(store.count);
const half = useAtomValue(store.half);
const dbl = useAtomValue(store.dbl);
const inc = useSetAtom(store.inc);

return (
<>
<div>count: {count}</div>
<div>half: {half}</div>
<div>dbl: {dbl}</div>
<button onClick={() => inc()}>inc</button>
</>
);
};

export default function App() {
return (
<div className="App">
<Counter />
</div>
);
}
10 changes: 10 additions & 0 deletions examples/03_atomic/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import App from './app';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
14 changes: 14 additions & 0 deletions examples/03_atomic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"strict": true,
"target": "es2018",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"allowJs": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"jsx": "react-jsx"
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"test:types:examples": "tsc -p examples --noEmit",
"test:spec": "vitest run",
"examples:01_typescript": "DIR=01_typescript vite",
"examples:02_create": "DIR=02_create vite"
"examples:02_create": "DIR=02_create vite",
"examples:03_atomic": "DIR=03_atomic vite"
},
"keywords": [
"jotai",
Expand Down
Loading

0 comments on commit d147e95

Please sign in to comment.