{"id":25912927,"url":"https://github.com/statofu/statofu-react","last_synced_at":"2025-03-03T10:17:31.316Z","repository":{"id":158054610,"uuid":"623947668","full_name":"statofu/statofu-react","owner":"statofu","description":"Predictable state changes at a low cost. Fast, and small.","archived":false,"fork":false,"pushed_at":"2023-07-07T12:10:42.000Z","size":239,"stargazers_count":1,"open_issues_count":0,"forks_count":3,"subscribers_count":2,"default_branch":"main","last_synced_at":"2024-04-25T16:42:45.362Z","etag":null,"topics":["functional","javascript","predictable","react","redux","state","state-management","statofu","statofu-react","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/statofu.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","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}},"created_at":"2023-04-05T12:30:12.000Z","updated_at":"2024-08-21T20:00:08.867Z","dependencies_parsed_at":null,"dependency_job_id":"1a0bff12-2899-4781-8c65-2e259a326daa","html_url":"https://github.com/statofu/statofu-react","commit_stats":{"total_commits":10,"total_committers":1,"mean_commits":10.0,"dds":0.0,"last_synced_commit":"0c38dd2f877503437f37a7c7eb2350ea01e3bf13"},"previous_names":[],"tags_count":6,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/statofu%2Fstatofu-react","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/statofu%2Fstatofu-react/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/statofu%2Fstatofu-react/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/statofu%2Fstatofu-react/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/statofu","download_url":"https://codeload.github.com/statofu/statofu-react/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":241460177,"owners_count":19966519,"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":["functional","javascript","predictable","react","redux","state","state-management","statofu","statofu-react","typescript"],"created_at":"2025-03-03T10:17:30.736Z","updated_at":"2025-03-03T10:17:31.310Z","avatar_url":"https://github.com/statofu.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003ch1 align=\"center\"\u003e\n  \u003cimg src=\"./assets/fa668bd880860a5790b4a5bc0d1d0f40adebd47d.jpg\" alt=\"Statofu React\" /\u003e\n\u003c/h1\u003e\n\n[![Coverage](https://img.shields.io/codecov/c/github/statofu/statofu-react/latest)](https://codecov.io/gh/statofu/statofu-react)\n[![Verify and release](https://img.shields.io/github/actions/workflow/status/statofu/statofu-react/verify-and-release.yml?branch=latest\u0026label=verify%20and%20release)](https://github.com/statofu/statofu-react/actions/workflows/verify-and-release.yml)\n[![Npm Version](https://img.shields.io/npm/v/statofu-react)](https://npmjs.com/package/statofu-react)\n[![Minzipped size](https://img.shields.io/bundlephobia/minzip/statofu-react)](https://bundlephobia.com/package/statofu-react)\n[![License](https://img.shields.io/github/license/statofu/statofu-react)](./LICENSE)\n\nEnglish | [中文](./README.zh-Hans.md)\n\n## Why Statofu React?\n\nOne big problem with today's widely accepted state management libraries is that predictable state changes have to come at a high cost. [A detailed article](https://github.com/statofu/statofu-blog/blob/main/20230525/README.en.md) was written for the explanation:\n\n\u003e ...\n\u003e\n\u003e Though, Redux is not perfect and has a drawback. If we take a closer look at its unidirectional data flow, `event -\u003e action -\u003e reducer -\u003e state`, it's lengthy. No matter how simple a state change is, always at least one action and at least one reducer are involved. In comparison, a state change in either Recoil or MobX goes much easier. The lengthiness dramatically increases the cost of use in Redux.\n\u003e\n\u003e ...\n\nStatofu is a state management library built to achieve **predictable state changes at a low cost** 🌈. It's framework-agnostic, small and fast. Statofu React is the library of the React integration.\n\n## Installation\n\n```sh\nnpm i -S statofu statofu-react # yarn or pnpm also works\n```\n\nThe state management library, [`statofu`](https://github.com/statofu/statofu), is required as the peer dependency of the React integration, `statofu-react`.\n\n## Essentials\n\nIn Statofu, each kind of state change directly involves a different reducer that accepts one or multiple old states along with zero or more payloads and produces one or multiple new corresponding states. As reducers are pure functions, state changes are predictable. As reducers are directly involved, state changes come at a low cost. The usage is described as follows. (Or, [an runnable example is available here](./examples/select-item-to-edit).)\n\n### Setting up the store\n\nFirst of all, a Statofu store needs to be set up for child components, for which `StoreProvider` is used. It can wrap either the whole app or only some components:\n\n```tsx\nimport { StoreProvider } from 'statofu-react';\n\n// ..., either:\n\nroot.render(\n  \u003cStoreProvider\u003e\n    \u003cApp /\u003e\n  \u003c/StoreProvider\u003e\n);\n\n// ..., or:\n\nconst SomeComponentsWithStore: React.FC = () =\u003e {\n  return (\n    \u003cStoreProvider\u003e\n      \u003cSomeComponents /\u003e\n    \u003c/StoreProvider\u003e\n  );\n};\n```\n\nBesides, `withStore` can be used as an alternative to `StoreProvider`:\n\n```tsx\nimport { withStore } from 'statofu-react';\n\n// ..., either:\n\nconst AppWithStore = withStore()(App);\n\nroot.render(\u003cAppWithStore /\u003e);\n\n// ..., or:\n\nconst SomeComponentsWithStore = withStore()(SomeComponents);\n```\n\n### Defining states\n\nNext, states need to be defined, which is simply done by Plain Old JavaScript Object(POJO)s. A POJO, as a state definition, simultaneously, for a state, (1) holds the default state value, (2) declares the state type, and (3) indexes the current state value in a store. Here are two example state definitions, one for a selectable item panel, and the other for a hideable text editor:\n\n```tsx\ninterface ItemPanelState {\n  itemList: { id: string; text: string }[];\n  selectedItemId: string | undefined;\n}\n\nconst $itemPanelState: ItemPanelState = {\n  itemList: [],\n  selectedItemId: undefined,\n};\n\ninterface TextEditorState {\n  text: string;\n  visible: boolean;\n}\n\nconst $textEditorState: TextEditorState = {\n  text: '',\n  visible: false,\n};\n```\n\nUsually, to distinguish state definitions from state values by names, `$` is prefixed to state definition names.\n\n### Getting states\n\nThen, to get state values in components, `useSnapshot` is used. It accepts one or multiple state definitions and returns the current one or multiple corresponding state values indexed by the state definitions:\n\n```tsx\nimport { useSnapshot } from 'statofu-react';\n\n// ...\n\nconst SomeComponent1: React.FC = () =\u003e {\n  const { itemList, selectedItemId } = useSnapshot($itemPanelState);\n  const { text, visible } = useSnapshot($textEditorState);\n\n  // ...\n};\n\n// ...\n\nconst SomeComponent2: React.FC = () =\u003e {\n  const [itemPanelState, textEditorState] = useSnapshot([$itemPanelState, $textEditorState]);\n\n  // ...\n};\n```\n\nBy the way, before a state is changed, its state value is the shallow copy of the default state value held by its state definition.\n\n### Changing states\n\nNow, let's dive into state changes. In Statofu, each kind of state change directly involves a different reducer. For changing one state, a reducer that accepts one old state along with zero or more payloads and produces one new corresponding state is involved. Here are three example reducers, two for changing `$itemPanelState`, and one for changing `$textEditorState`:\n\n```tsx\nfunction selectItem(state: ItemPanelState, itemIdToSelect: string): ItemPanelState {\n  return { ...state, selectedItemId: itemIdToSelect };\n}\n\nfunction unselectItem(state: ItemPanelState): ItemPanelState {\n  return { ...state, selectedItemId: undefined };\n}\n\nfunction setText(state: TextEditorState, text: string): TextEditorState {\n  return { ...state, text };\n}\n```\n\nFor changing multiple states, a reducer that accepts multiple old states along with zero or more payloads and produces multiple new corresponding states is involved. Here is an example reducer for changing `$itemPanelState` and `$textEditorState`:\n\n```tsx\nfunction submitTextForSelectedItem([textEditor, itemPanel]: [TextEditorState, ItemPanelState]): [\n  TextEditorState,\n  ItemPanelState\n] {\n  return [\n    { ...textEditor, visible: false },\n    {\n      ...itemPanel,\n      itemList: itemPanel.itemList.map((item) =\u003e {\n        if (item.id === itemPanel.selectedItemId) {\n          return { ...item, text: textEditor.text };\n        } else {\n          return item;\n        }\n      }),\n      selectedItemId: undefined,\n    },\n  ];\n}\n```\n\nWith reducers ready, to involve them to change states in components, the operating function returned by `useOperate` is used:\n\n```tsx\nimport { useOperate } from 'statofu-react';\n\n// ...\n\nconst SomeComponent3: React.FC = () =\u003e {\n  const op = useOperate();\n\n  function handleItemClick(itemId: string) {\n    op($itemPanelState, selectItem, itemId);\n  }\n\n  function handleQuitClick() {\n    op($itemPanelState, unselectItem);\n  }\n\n  function handleTextareaChange(e: React.ChangeEvent\u003cHTMLTextAreaElement\u003e) {\n    op($textEditorState, setText, e.target.value);\n  }\n\n  function handleSubmitClick() {\n    op([$textEditorState, $itemPanelState], submitTextForSelectedItem);\n  }\n\n  return \u003c\u003e{/* attaches event handlers */}\u003c/\u003e;\n};\n```\n\nInside a call of an operating function, the current state values indexed by the state definitions are passed into the reducer to produce the next state values which are, in turn, saved to the store.\n\n### Deriving data\n\nFurthurmore, to derive data from states, a selector that accepts one or multiple states along with zero or more payloads and calculates a value can be passed in while using `useSnapshot`. Selectors can be named functions:\n\n```tsx\nfunction getSelectedItem(state: ItemPanelState): ItemPanelState['itemList'][number] | undefined {\n  return state.itemList.find(({ id }) =\u003e id === state.selectedItemId);\n}\n\nfunction getRelatedItems([itemPanel, textEditor]: [\n  ItemPanelState,\n  TextEditorState\n]): ItemPanelState['itemList'] {\n  return itemPanel.itemList.filter(({ text }) =\u003e text.includes(textEditor.text));\n}\n\nfunction getTextWithFallback(state: TextEditorState, fallback: string): string {\n  return state.text || fallback;\n}\n\nfunction isVisible(state: TextEditorState): boolean {\n  return state.visible;\n}\n\nconst SomeComponent5: React.FC = () =\u003e {\n  const selectedItem = useSnapshot($itemPanelState, getSelectedItem);\n  const relatedItems = useSnapshot([$itemPanelState, $textEditorState], getRelatedItems);\n  const textWithFallback = useSnapshot($textEditorState, getTextWithFallback, 'Not Available');\n  const visible = useSnapshot($textEditorState, isVisible);\n\n  // ...\n};\n```\n\nAlso, selectors can be anonymous functions:\n\n```tsx\nconst SomeComponent6: React.FC = () =\u003e {\n  const selectedItemId = useSnapshot($itemPanelState, (state) =\u003e state.selectedItemId);\n\n  // ...\n};\n```\n\nNote that, given the same inputs to a selector, the non-array outputs or the elements of the array outputs should remain referentially identical across separate calls so unnecessary rerenders are avoided.\n\n## Recipes\n\n### Code Structure\n\nIn Statofu, the management of a state consists of (1) a state definition, (2) zero or more reducers, and (3) zero or more selectors. So, a recommended practice is to place the three parts of a state sequentially into one file, which leads to good maintainability. (In addition, as there are only POJOs and pure functions in each file, this code structure also leads to good portability.) Let's reorganize the states in Essentials for an example:\n\n```tsx\n// states/ItemPanelState.ts\nimport type { TextEditorState } from './TextEditorState';\n\nexport interface ItemPanelState {\n  itemList: { id: string; text: string }[];\n  selectedItemId: string | undefined;\n}\n\nexport const $itemPanelState: ItemPanelState = {\n  itemList: [],\n  selectedItemId: undefined,\n};\n\nexport function selectItem(state: ItemPanelState, itemIdToSelect: string): ItemPanelState {\n  // ...\n}\n\nexport function unselectItem(state: ItemPanelState): ItemPanelState {\n  // ...\n}\n\nexport function getSelectedItem(\n  state: ItemPanelState\n): ItemPanelState['itemList'][number] | undefined {\n  // ...\n}\n\nexport function getRelatedItems([itemPanel, textEditor]: [\n  ItemPanelState,\n  TextEditorState\n]): ItemPanelState['itemList'] {\n  // ...\n}\n```\n\n```tsx\n// states/TextEditorState.ts\nimport type { ItemPanelState } from './ItemPanelState';\n\nexport interface TextEditorState {\n  text: string;\n  visible: boolean;\n}\n\nexport const $textEditorState: TextEditorState = {\n  text: '',\n  visible: false,\n};\n\nexport function setText(state: TextEditorState, text: string): TextEditorState {\n  // ...\n}\n\nexport function submitTextForSelectedItem([textEditor, itemPanel]: [\n  TextEditorState,\n  ItemPanelState\n]): [TextEditorState, ItemPanelState] {\n  // ...\n}\n\nexport function getTextWithFallback(state: TextEditorState, fallback: string): string {\n  // ...\n}\n\nexport function isVisible(state: TextEditorState): boolean {\n  // ...\n}\n```\n\n### Server-side rendering(SSR)\n\nIn general, SSR needs 2 steps. (1) On the server side, states are prepared as per a page request, an HTML body is rendered with the states, and the states are serialized afterward. Then, the two are piped into the response. (2) On the client side, the server-serialized states are deserialized, then components are rendered with the states to properly hydrate the server-rendered HTML body.\n\nTo help with SSR, Statofu provides helpers of bulk reading to-serialize states from a store and bulk writing deserialized states to a store. But, serialization/deserialization is beyond the scope because it's easily doable via a more specialized library such as `serialize-javascript` or some built-in features of a full-stack framework such as data fetching of `next`.\n\nHere is a semi-pseudocode example for SSR with Statofu. Firstly, `serialize-javascript` is installed for serialization/deserialization:\n\n```sh\nnpm i -S serialize-javascript\n```\n\nNext, on the server side:\n\n```tsx\nimport { renderToString } from 'react-dom/server';\nimport serialize from 'serialize-javascript';\nimport { createStatofuState } from 'statofu';\nimport { StoreProvider } from 'statofu-react';\nimport { foldStates } from 'statofu/ssr';\n\n// ...\n\napp.get('/some-page', (req, res) =\u003e {\n  const store = createStatofuState();\n\n  const itemPanelState = prepareItemPanelState(req);\n  store.operate($itemPanelState, itemPanelState);\n\n  const textEditorState = prepareItemPanelState(req);\n  store.operate($textEditorState, textEditorState);\n\n  const htmlBody = renderToString(\n    \u003cStoreProvider store={store}\u003e\n      \u003cApp /\u003e\n    \u003c/StoreProvider\u003e\n  );\n\n  const stateFolder = foldStates(store, { $itemPanelState, $textEditorState });\n\n  res.send(`\n...\n\u003cscript\u003ewindow.SERIALIZED_STATE_FOLDER='${serialize(stateFolder)}'\u003c/script\u003e\n...\n\u003cdiv id=\"root\"\u003e${htmlBody}\u003c/div\u003e\n...`);\n});\n```\n\nAfterward, on the client side:\n\n```tsx\nimport { hydrateRoot } from 'react-dom/client';\nimport { StoreProvider } from 'statofu-react';\nimport { unfoldStates } from 'statofu/ssr';\n\n// ...\n\nconst stateFolder = eval(`(${window.SERIALIZED_STATE_FOLDER})`);\n\ndelete window.SERIALIZED_STATE_FOLDER;\n\nhydrateRoot(\n  elRoot,\n  \u003cStoreProvider\n    onCreate={(store) =\u003e {\n      unfoldStates(store, { $itemPanelState, $textEditorState }, stateFolder);\n    }}\n  \u003e\n    \u003cApp /\u003e\n  \u003c/StoreProvider\u003e\n);\n```\n\nNote that, this example can be optimized in different ways like rendering the HTML body as a stream. When using it in the real world, we should tailor it to real-world needs.\n\n## APIs\n\n### `StoreProvider`\n\nThe component to set up a Statofu store for child components:\n\n```tsx\n\u003cStoreProvider\u003e\n  \u003cApp /\u003e\n\u003c/StoreProvider\u003e\n```\n\nOptions:\n\n- `store?: StatofuStore`: The store provided outside.\n- `onCreate?: (store: StatofuStore) =\u003e void`: The callback invoked on a store created inside. If `store` is present, the callback is not called.\n\n### `withStore`\n\nThe higher-order component(HOC) version of `StoreProvider`:\n\n```tsx\nconst AppWithStore = withStore(/* options */)(App);\n```\n\nOptions: same as `StoreProvider`'s.\n\n### `useStore`\n\nThe hook to get the store:\n\n```tsx\nconst store = useStore();\n```\n\n### `useSnapshot`\n\nThe hook to get state values:\n\n```tsx\nconst { itemList, selectedItemId } = useSnapshot($itemPanelState);\nconst { text, visible } = useSnapshot($textEditorState);\nconst [itemPanelState, textEditorState] = useSnapshot([$itemPanelState, $textEditorState]);\n```\n\nIt can accept selectors:\n\n```tsx\nconst selectedItem = useSnapshot($itemPanelState, getSelectedItem);\nconst relatedItems = useSnapshot([$itemPanelState, $textEditorState], getRelatedItems);\nconst textWithFallback = useSnapshot($textEditorState, getTextWithFallback, 'Not Available');\nconst visible = useSnapshot($textEditorState, isVisible);\nconst selectedItemId = useSnapshot($itemPanelState, (state) =\u003e state.selectedItemId);\n```\n\n### `useOperate`\n\nThe hook to return the operating function for changing states by involving reducers:\n\n```tsx\nconst op = useOperate();\n\nfunction handleItemClick(itemId: string) {\n  op($itemPanelState, selectItem, itemId);\n}\n\nfunction handleQuitClick() {\n  op($itemPanelState, unselectItem);\n}\n\nfunction handleTextareaChange(e: React.ChangeEvent\u003cHTMLTextAreaElement\u003e) {\n  op($textEditorState, setText, e.target.value);\n}\n\nfunction handleSubmitClick() {\n  op([$textEditorState, $itemPanelState], submitTextForSelectedItem);\n}\n```\n\n## Contributing\n\nFor any bugs or any thoughts, welcome to [open an issue](https://github.com/statofu/statofu-react/issues), or just DM me on [Twitter](https://twitter.com/licg9999) / [Wechat](https://github.com/statofu/statofu/blob/main/assets/ed0458952a4930f1aeebd01da0127de240c85bbf.jpg).\n\n## License\n\nMIT, details in the [LICENSE](./LICENSE) file.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fstatofu%2Fstatofu-react","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fstatofu%2Fstatofu-react","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fstatofu%2Fstatofu-react/lists"}