{"id":25144720,"url":"https://github.com/will-stone/2n8","last_synced_at":"2025-04-28T11:21:22.292Z","repository":{"id":266080861,"uuid":"897220577","full_name":"will-stone/2n8","owner":"will-stone","description":"Minimal React state boilerplate.","archived":false,"fork":false,"pushed_at":"2025-04-19T09:33:27.000Z","size":1130,"stargazers_count":12,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-04-19T14:57:20.069Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/will-stone.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null},"funding":{"github":"will-stone","custom":"https://www.buymeacoffee.com/wstone"}},"created_at":"2024-12-02T08:54:02.000Z","updated_at":"2025-04-19T09:33:30.000Z","dependencies_parsed_at":"2024-12-02T13:43:29.761Z","dependency_job_id":"7907ce21-b2c4-431a-a6f0-2943d651a608","html_url":"https://github.com/will-stone/2n8","commit_stats":null,"previous_names":["will-stone/2n8"],"tags_count":45,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/will-stone%2F2n8","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/will-stone%2F2n8/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/will-stone%2F2n8/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/will-stone%2F2n8/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/will-stone","download_url":"https://codeload.github.com/will-stone/2n8/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":251302769,"owners_count":21567601,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-02-08T19:47:33.721Z","updated_at":"2025-04-28T11:21:22.284Z","avatar_url":"https://github.com/will-stone.png","language":"TypeScript","funding_links":["https://github.com/sponsors/will-stone","https://www.buymeacoffee.com/wstone"],"categories":[],"sub_categories":[],"readme":"# 🫤 2n8\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"https://raw.githubusercontent.com/will-stone/2n8/main/media/logo.png\" alt=\"tings\" width=\"200\" height=\"200\" /\u003e\n\u003c/p\u003e\n\n\u003e Oh my, your store is in a right\n\u003e [two and eight](https://cockneyrhymingslang.co.uk/slang/two_and_eight/).\n\nA lightweight JavaScript / TypeScript state management library that uses a\nclass-based store.\n\nKey features include:\n\n- Action based state flow.\n- Built-in subscription system for reactive updates.\n- Flexible state reset functionality for entire state or specific fields.\n- Type-safe state management.\n- Minimal boilerplate.\n\n```tsx\nimport { TwoAndEight, createReactStore } from '2n8'\n\nclass Store extends TwoAndEight {\n  count = 0\n\n  addClicked() {\n    this.count++\n  }\n\n  resetClicked() {\n    this.$reset('count')\n  }\n}\n\nconst useStore = createReactStore(new Store())\n\nconst Counter = () =\u003e {\n  const count = useStore('count')\n  const addClicked = useStore('addClicked')\n  const resetClicked = useStore('resetClicked')\n  return (\n    \u003cdiv\u003e\n      \u003cspan\u003e{count}\u003c/span\u003e\n      \u003cbutton onClick={addClicked}\u003eOne up\u003c/button\u003e\n      \u003cbutton onClick={resetClicked}\u003eReset\u003c/button\u003e\n    \u003c/div\u003e\n  )\n}\n```\n\n## Getting Started\n\n### Installation\n\n```sh\nnpm i 2n8\nyarn add 2n8\npnpm add 2n8\nbun add 2n8\n```\n\n### Create a store\n\nYour store is a class, and turning it into a React hook is as easy as passing it\nto the `createReactStore` utility.\n\n```ts\n// store.ts\nimport { TwoAndEight, createReactStore } from '2n8'\n\nclass Store extends TwoAndEight {\n  expression: '🫤' | '🥸' = '🫤'\n\n  addDisguise() {\n    this.expression = '🥸'\n  }\n\n  resetToConfusion() {\n    this.$reset('expression')\n  }\n}\n\nexport const useStore = createReactStore(new Store())\n```\n\n### Import the hook into your React components\n\nThe hook provides a direct connection to your store. When you modify the state,\nthe consuming component automatically re-renders to reflect those changes.\n\n```tsx\n// Expression.tsx\nimport { useStore } from './store'\n\nfunction Expression() {\n  const expression = useStore('expression')\n  return \u003ch1\u003e{expression}\u003c/h1\u003e\n}\n```\n\n```tsx\n// App.tsx\nimport { useStore } from './store'\n\nfunction App() {\n  const addDisguise = useStore('addDisguise')\n  const resetToConfusion = useStore('resetToConfusion')\n  return (\n    \u003c\u003e\n      \u003cbutton onClick={addDisguise}\u003eHide!\u003c/button\u003e\n      \u003cbutton onClick={resetToConfusion}\u003eWhat?!\u003c/button\u003e\n    \u003c/\u003e\n  )\n}\n```\n\n## State and Actions\n\n_State_ is initiated using class fields. The class must be extended from 2n8's\nparent class which enhances the store with a few utilities.\n\n```tsx\n// store.ts\nimport { TwoAndEight } from '2n8'\n\nclass Store extends TwoAndEight {\n  counter = 0\n}\n```\n\nState changes are made inside _actions_, which are simply class methods that\nmutate the fields.\n\n```tsx\n// store.ts\nclass Store extends TwoAndEight {\n  counter = 0\n\n  addButtonClicked() {\n    this.counter++\n  }\n}\n```\n\nGenerate your React hook:\n\n```tsx\n// store.ts\nexport const useStore = createReactStore(new Store())\n```\n\nThis uses React's\n[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)\nhook to _subscribe_ to state changes:\n\n```tsx\n// Component.tsx\nimport { useStore } from './store'\n\nconst Component = () =\u003e {\n  const counter = useStore('counter')\n\n  return \u003cdiv\u003e{counter}\u003c/div\u003e\n}\n```\n\nWhen, and only when, the selected state changes, the component is rerendered by\nReact. This is more optimal than simply passing state down the component tree\nvia props.\n\nSelect and call actions from the store:\n\n```tsx\n// Component2.tsx\nimport { useStore } from './store'\n\nconst Component2 = () =\u003e {\n  const addButtonClicked = useStore('addButtonClicked')\n\n  return \u003cbutton onClick={addButtonClicked}\u003eAdd\u003c/button\u003e\n}\n```\n\nWhen actions are called, the current values of all state are _emitted_ to the\nsubscribers at the end of the action.\n\n## Async Actions\n\nRunning asynchronous actions is as simple as making an async method on your\nstore class.\n\n```tsx\n// store.ts\nimport { TwoAndEight } from '2n8'\n\nimport { fetchData } from './data-fetcher'\n\nclass Store extends TwoAndEight {\n  data: { id: string; name: string }[] = []\n\n  async loadDataButtonClicked() {\n    this.data = await fetchData()\n  }\n}\n```\n\nAs state is only emitted at the **end** of actions, you may find you'd like to\nemit earlier to trigger state changes in your app. For this you can use the\nspecial `$emit` action.\n\n```tsx\n// store.ts\nimport { TwoAndEight } from '2n8'\n\nimport { fetchData } from './data-fetcher'\n\nclass Store extends TwoAndEight {\n  data: { id: string; name: string }[] = []\n  status: 'idle' | 'pending' = 'idle'\n\n  async loadDataButtonClicked() {\n    this.status = 'pending'\n    this.$emit()\n    this.data = await fetchData()\n    this.status = 'idle'\n  }\n}\n```\n\n\u003e [!WARNING]  \n\u003e All state currently set within the store will be emitted when you call the\n\u003e `$emit` action. This includes changes made by other actions in this time.\n\n## Derived State\n\nState values based on one or more other state values, known as derived state,\ncan be created using _getters_.\n\n```tsx\n// store.ts\nimport { TwoAndEight } from '2n8'\n\nclass Store extends TwoAndEight {\n  counter = 0\n  secondCounter = 10\n\n  get totalCounters() {\n    return this.counter + this.secondCounter\n  }\n}\n\nexport const useStore = createReactStore(new Store())\n```\n\nAny subscribers to `totalCounters` will update when either `counter` or\n`secondCounter` are updated.\n\n```tsx\n// Component.tsx\nimport { useStore } from './store'\n\nconst Component = () =\u003e {\n  const totalCounters = useStore('totalCounters')\n\n  return \u003cdiv\u003e{totalCounters}\u003c/div\u003e\n}\n```\n\n## Reset State\n\nIf you need to reset a state value to its initial value, you can call the\nspecial `$reset` action.\n\n```tsx\n// store.ts\nimport { TwoAndEight } from '2n8'\n\nclass Store extends TwoAndEight {\n  counter = 0\n\n  addButtonClicked() {\n    this.counter++\n  }\n\n  resetButtonClicked() {\n    this.$reset('counter')\n  }\n}\n\nexport const useStore = createReactStore(new Store())\n```\n\n```tsx\n// Component.tsx\nimport { useStore } from './store'\n\nconst Component = () =\u003e {\n  const counter = useStore('counter')\n  const addButtonClicked = useStore('addButtonClicked')\n  const resetButtonClicked = useStore('resetButtonClicked')\n\n  return (\n    \u003cdiv\u003e\n      \u003cdiv\u003e{counter}\u003c/div\u003e\n      \u003cbutton onClick={addButtonClicked}\u003eAdd\u003c/button\u003e\n      \u003cbutton onClick={resetButtonClicked}\u003eReset\u003c/button\u003e\n    \u003c/div\u003e\n  )\n}\n```\n\nIn the above example, clicking `Add` will update the displayed `counter` to `1`.\nClicking `Reset` will put the `counter` back to `0`.\n\n\u003e [!TIP]  \n\u003e You can call `$reset()` without a field parameter to reset _all_ state in the\n\u003e store.\n\n## Comparison\n\n2n8 feels like a blend between two excellent state management libraries:\n[Zustand](https://zustand.docs.pmnd.rs/) and [MobX](https://mobx.js.org/).\nTherefore, here's a quick comparison with those two packages.\n\n\u003e [!IMPORTANT]  \n\u003e There are always compromises. 2n8 aims for simplicity when setting up your\n\u003e store code using TypeScript, but the other two libraries mentioned here are\n\u003e far more mature and have a great ecosystem and community. Please use the tool\n\u003e that best suits your use case.\n\n### Boilerplate\n\nThe main reason for creating 2n8 was to limit the amount of boilerplate and\nrepetition required to make a store when using TypeScript. Here's a simple\ncounter example:\n\n#### 2n8\n\n```tsx\nimport { TwoAndEight, createReactStore } from '2n8'\n\nclass Store extends TwoAndEight {\n  count = 0\n\n  addClicked() {\n    this.count++\n  }\n\n  resetClicked() {\n    this.$reset('count')\n  }\n}\n\nconst useStore = createReactStore(new Store())\n\nconst Counter = () =\u003e {\n  const count = useStore('count')\n  const addClicked = useStore('addClicked')\n  const resetClicked = useStore('resetClicked')\n  return (\n    \u003cdiv\u003e\n      \u003cspan\u003e{count}\u003c/span\u003e\n      \u003cbutton onClick={addClicked}\u003eOne up\u003c/button\u003e\n      \u003cbutton onClick={resetClicked}\u003eReset\u003c/button\u003e\n    \u003c/div\u003e\n  )\n}\n```\n\n#### Zustand\n\n```tsx\nimport { create } from 'zustand'\n\ntype State = {\n  count: number\n}\n\ntype Actions = {\n  addClicked: () =\u003e void\n  resetClicked: () =\u003e void\n}\n\nconst initialState: State = {\n  count: 0,\n}\n\nconst useStore = create\u003cState \u0026 Actions\u003e()((set) =\u003e ({\n  ...initialState,\n  addClicked: () =\u003e\n    set((state) =\u003e ({\n      ...state,\n      count: state.count + 1,\n    })),\n  resetClicked: () =\u003e\n    set((state) =\u003e ({\n      ...state,\n      count: initialState.count,\n    })),\n}))\n\nconst Counter = () =\u003e {\n  const count = useStore((state) =\u003e state.count)\n  const addClicked = useStore((state) =\u003e state.addClicked)\n  const resetClicked = useStore((state) =\u003e state.resetClicked)\n  return (\n    \u003cdiv\u003e\n      \u003cspan\u003e{count}\u003c/span\u003e\n      \u003cbutton onClick={addClicked}\u003eOne up\u003c/button\u003e\n      \u003cbutton onClick={resetClicked}\u003eReset\u003c/button\u003e\n    \u003c/div\u003e\n  )\n}\n```\n\n#### Mobx\n\n```tsx\nimport { observer } from 'mobx-react-lite'\nimport { makeAutoObservable } from 'mobx'\n\ntype State = {\n  count: number\n}\n\nconst initialState: State = {\n  count: 0,\n}\n\nclass Store {\n  count = initialState.count\n\n  constructor() {\n    makeAutoObservable(this)\n  }\n\n  addClicked() {\n    this.count++\n  }\n\n  resetClicked() {\n    this.count = initialState.count\n  }\n}\n\nconst store = new Store()\n\nconst Counter = observer(() =\u003e {\n  return (\n    \u003cdiv\u003e\n      \u003cspan\u003e{store.count}\u003c/span\u003e\n      \u003cbutton onClick={store.addClicked}\u003eOne up\u003c/button\u003e\n      \u003cbutton onClick={store.resetClicked}\u003eReset\u003c/button\u003e\n    \u003c/div\u003e\n  )\n})\n```\n\nIn this example, 2n8 requires the least store boilerplate whereas MobX needs\nless component binding.\n\nThe advantage of 2n8's concise store implementation is that it doesn't require\nexternal type definitions or an initial state object. TypeScript can infer types\ninside the class too; take another look at the 2n8 example, there's no types in\nsight, but this store automatically has the correct types for both state and\nactions.\n\n### Features\n\n|                                                 | 2n8                               | Zustand                                                                                                 | MobX                       |\n| ----------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------- |\n| When do subscribers run?                        | After action (or on manual emit). | After set state.                                                                                        | At the end of actions.     |\n| What equality checks are made on state changes? | Deep equality check is built-in.  | Uses `Object.is` by default for equality, and shallow or deep equality checking must be manually added. | Deep changes are observed. |\n| How do components connect to state and actions? | Hooks.                            | Hooks.                                                                                                  | Observer wrapper function. |\n\n### Bundle size\n\n|                          | Bundle size | GZipped | Notes                                                                      |\n| ------------------------ | ----------- | ------- | -------------------------------------------------------------------------- |\n| [2n8][2n8-bench]         | 11.3 kB     | 4.32 kB |                                                                            |\n| [Zustand][zustand-bench] | 17.4 kB     | 6.53 kB | Includes `useShallow` hook and `immer` middleware to match feature parity. |\n| [MobX][mobx-bench]       | 74.9 kB     | 21.9 kB |                                                                            |\n\n[2n8-bench]:\n  https://bundlejs.com/?q=2n8%400.12.1\u0026treeshake=%5B%7BTwoAndEight%2CcreateReactStore%7D%5D\n[zustand-bench]:\n  https://bundlejs.com/?q=zustand%405.0.3%2Czustand%405.0.3%2Freact%2Fshallow%2Czustand%2Fmiddleware%2Fimmer\u0026treeshake=%5B%7B+create+%7D%5D%2C%5B%7B+useShallow+%7D%5D%2C%5B%7B+immer+%7D%5D\n[mobx-bench]:\n  https://bundlejs.com/?q=mobx%406.13.5%2Cmobx-react-lite%404.1.0\u0026treeshake=%5B%7BmakeAutoObservable%7D%5D%2C%5B%7Bobserver%7D%5D\n\n### Benchmarks\n\nHere's a benchmark for the libraries running in React on an Apple MacBook Air\nM2. It shows that the libraries all display very similar performance.\n\nRun 1:\n\n```\n✓ src/react.bench.tsx \u003e simple count 1874ms\n    name          hz      min      max     mean      p75      p99     p995     p999     rme  samples\n  · 2n8      75.8525  11.6857  14.8863  13.1835  13.6438  14.8863  14.8863  14.8863  ±1.87%       38   fastest\n  · mobx     74.2115  11.6380  18.4382  13.4750  13.6820  18.4382  18.4382  18.4382  ±3.23%       38\n  · zustand  72.6212  11.7150  19.2436  13.7701  14.7164  19.2436  19.2436  19.2436  ±3.85%       37   slowest\n\nBENCH  Summary\n\n2n8 - src/react.bench.tsx \u003e simple count\n  1.02x faster than mobx\n  1.04x faster than zustand\n```\n\nRun 2:\n\n```\n✓ src/react.bench.tsx \u003e simple count 1881ms\n    name          hz      min      max     mean      p75      p99     p995     p999     rme  samples\n  · 2n8      75.1111  11.7725  17.8066  13.3136  13.6982  17.8066  17.8066  17.8066  ±2.45%       38   fastest\n  · mobx     72.5903  11.9117  17.1061  13.7759  14.4564  17.1061  17.1061  17.1061  ±3.28%       37   slowest\n  · zustand  74.0253  11.5298  16.7901  13.5089  14.3196  16.7901  16.7901  16.7901  ±2.92%       38\n\nBENCH  Summary\n\n2n8 - src/react.bench.tsx \u003e simple count\n  1.01x faster than zustand\n  1.03x faster than mobx\n```\n\nRun 3:\n\n```\n✓ src/react.bench.tsx \u003e simple count 1890ms\n    name          hz      min      max     mean      p75      p99     p995     p999     rme  samples\n  · 2n8      74.5884  11.6642  16.5621  13.4069  13.6273  16.5621  16.5621  16.5621  ±1.81%       38\n  · mobx     73.2655  11.6450  16.9816  13.6490  14.1171  16.9816  16.9816  16.9816  ±3.48%       37   slowest\n  · zustand  74.8296  11.3695  16.7610  13.3637  14.3461  16.7610  16.7610  16.7610  ±3.23%       38   fastest\n\nBENCH  Summary\n\nzustand - src/react.bench.tsx \u003e simple count\n  1.00x faster than 2n8\n  1.02x faster than mobx\n```\n\n## API\n\n### `TwoAndEight`\n\nThe abstract class that all stores must extend if you would like to use the\nfollowing utility methods. The class also\n[auto-binds](https://www.npmjs.com/package/auto-bind) your actions so you don't\nneed to use arrow functions or bind methods in the constructor.\n\n```ts\nclass Store extends TwoAndEight {\n  // ...\n}\n```\n\n#### Fields\n\nCustom fields are your state, and should only be mutated in your actions.\n\n#### Methods\n\nCustom methods are your actions, and should be used to mutate state.\n\nThere are also some in-built actions. All in-built actions will always be\nprefixed with a `$` to avoid clashing with your own action names.\n\n##### `$emit`\n\n```ts\n$emit(): void\n```\n\nEmit to subscribers early instead of waiting until the end of the action. This\nis useful in asynchronous actions where you may want subscribers to update\nbefore the async event has finished.\n\n```ts\nclass Store extends TwoAndEight {\n  isFetching = false\n\n  async actionName() {\n    this.isFetching = true\n    this.$emit()\n    await fetchThing()\n    this.isFetching = false\n  }\n}\n```\n\n##### `$reset`\n\n```ts\n$reset(stateName?: string): void\n```\n\nCall this to reset the state to its original value. Use a state name to reset a\nsingle field of state, or call it without any arguments to reset _all_ state to\ntheir original values.\n\n```ts\nthis.$reset()\nthis.$reset('stateName')\n```\n\n```ts\nclass Store extends TwoAndEight {\n  counter = 0\n\n  resetCounter() {\n    this.$reset('counter')\n  }\n\n  resetAll() {\n    this.$reset()\n  }\n}\n```\n\n### `createReactStore`\n\n```ts\ncreateReactStore(store: Store extends TwoAndEight): useStore\n```\n\nEnhances a store instance, returning a React Hook with API utilities attached.\nThis should only be called _outside_ of components.\n\n```ts\nconst useStore = createReactStore(new Store())\n\nconst Component = () =\u003e {\n  const actionName = useStore('actionName')\n  const stateName = useStore('stateName')\n  // ...\n}\n```\n\n#### `useStore.store`\n\n```ts\nuseStore.store: Store\n```\n\nA re-export of the store, useful in subscribers where hooks are not available.\n\n#### `useStore.subscribe`\n\n```ts\nuseStore.subscribe(callback: () =\u003e void): () =\u003e void\n```\n\nSubscribes to state updates; registers a callback that fires whenever an action\nemits. This can be used to trigger events when all or certain state changes.\n\n```ts\nuseStore.subscribe(() =\u003e {\n  writeCounterToFile(useStore.store.counter)\n})\n```\n\nNote that this will be called on every emitted state from the store. If you'd\nlike to optimise, it is advisable to use `if` statements and an external cache:\n\n```ts\nlet counterCache = useStore.store.counter\n\nuseStore.subscribe(() =\u003e {\n  if (useStore.store.counter !== counterCache) {\n    writeCounterToFile(useStore.store.counter)\n    counterCache = useStore.store.counter\n  }\n})\n```\n\n### `createStore`\n\n```ts\ncreateStore(store: Store extends TwoAndEight): store\n```\n\nThis is the vanilla store creator used by `createReactStore`. You should only\nneed this if you are creating other, non-React, integrations with a 2n8 store.\n\n#### `store.store`\n\n```ts\nstore.store: Store\n```\n\nA re-export of the store, useful in subscribers where hooks are not available.\n\n#### `store.subscribe`\n\n```ts\nstore.subscribe(callback: () =\u003e void): () =\u003e void\n```\n\nSubscribes to state updates; registers a callback that fires whenever an action\nemits. This can be used to trigger events when all or certain state changes.\n\n#### `store.getInitialState`\n\n```ts\nstore.getInitialState(): Store\n```\n\nReturns the initial state snapshot, before any mutations have occurred.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwill-stone%2F2n8","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwill-stone%2F2n8","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwill-stone%2F2n8/lists"}