Repository: velopert/sangte Branch: main Commit: 08f1a7701f17 Files: 64 Total size: 75.5 KB Directory structure: gitextract_d21tq2rb/ ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README-ko.md ├── README.md ├── babel.config.js ├── examples/ │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.css │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── Counter.tsx │ │ │ ├── GlobalState.tsx │ │ │ ├── Hydrate.tsx │ │ │ ├── Inherit.tsx │ │ │ ├── Initialize.tsx │ │ │ ├── MemoizedSelector.tsx │ │ │ ├── MemoryLeakTester.tsx │ │ │ ├── MultiProviders.tsx │ │ │ ├── Selector.tsx │ │ │ ├── Todos.tsx │ │ │ └── VisibleToggle.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── index.html ├── jest.config.ts ├── package.json ├── rollup.config.js ├── src/ │ ├── contexts/ │ │ ├── SangteProvider.tsx │ │ ├── __tests__/ │ │ │ └── SangteProvider.test.tsx │ │ └── index.ts │ ├── hooks/ │ │ ├── __tests__/ │ │ │ ├── useResetAllSangte.test.tsx │ │ │ ├── useResetSangte.test.ts │ │ │ ├── useSangte.test.ts │ │ │ ├── useSangteActions.test.ts │ │ │ ├── useSangteCallback.test.ts │ │ │ ├── useSangteStore.test.ts │ │ │ ├── useSangteValue.test.ts │ │ │ └── useSetSangte.test.ts │ │ ├── index.ts │ │ ├── useResetAllSangte.ts │ │ ├── useResetSangte.ts │ │ ├── useSangte.ts │ │ ├── useSangteActions.ts │ │ ├── useSangteCallback.ts │ │ ├── useSangteStore.ts │ │ ├── useSangteValue.ts │ │ └── useSetSangte.ts │ ├── index.ts │ ├── lib/ │ │ ├── SangteInitializer.ts │ │ ├── SangteManager.ts │ │ ├── __tests__/ │ │ │ ├── SangteManager.test.ts │ │ │ ├── sangte.test.ts │ │ │ └── shallowEqual.test.ts │ │ ├── index.ts │ │ ├── sangte.ts │ │ └── shallowEqual.ts │ └── setupTest.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: [push, pull_request] jobs: run: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Node 18 uses: actions/setup-node@v3 with: node-version: 18 - name: Install dependencies run: yarn - name: Run tests and collect coverage run: yarn test - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? coverage/* ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "semi": false, "printWidth": 100 } ================================================ FILE: LICENSE.md ================================================ The MIT License (MIT) Copyright (c) 2022-present velopert Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README-ko.md ================================================ # sangte [![](https://img.shields.io/npm/v/sangte?style=flat-square)](https://www.npmjs.com/package/sangte) [![](https://img.shields.io/bundlephobia/min/sangte?style=flat-square)](https://bundlephobia.com/package/sangte) [![](https://img.shields.io/codecov/c/github/velopert/sangte?style=flat-square)](https://app.codecov.io/gh/velopert/sangte) Sangte 는 리액트의 상태 관리 라이브러리입니다. 이 라이브러리는 [Redux Toolkit](https://redux-toolkit.js.org/)과 [Recoil](https://recoiljs.org/)에서 영감을 받아 만들어졌습니다. > Sangte는 "상태"의 발음을 알파벳으로 표기한 것입니다. ## 설치 다음 명령어로 이 라이브러리를 설치하세요: ``` npm install sangte ``` yarn을 사용하신다면: ``` yarn add sangte ``` ## 왜 Sangte 를 쓰나요? - 준비해야 할 코드가 적음 (Less boilerplate) - 원하는 상태가 업데이트 됐을 때만 리렌더링함 - 사용하기 쉬움 - 여러개의 Provider 허용 - 타입스크립트 지원 ## 사용법 ### 먼저, 상태를 만드세요 상태를 만드려면 `sangte` 함수를 사용해야 합니다. 이 라이브러리의 상태는 초깃값이 있어야 하며, 상태를 업데이트하는 액션을 가질 수 있습니다. 액션은 선택사항입니다. Sangte는 내부적으로 [immer](https://immerjs.github.io/immer/)를 사용하여 상태를 업데이트합니다. 그래서 상태를 직접 변경하면서 불변성을 유지할 수 있습니다. ```ts import { sangte } from 'sangte' const counterState = sangte(0) const textState = sangte('text') const userState = sangte({ id: 1, name: 'John', email: 'john@email.com' }, (prev) => ({ setName(name: string) { prev.name = name }, setEmail(email: string) { return { ...prev, email, } }, })) interface Todo { id: number text: string done: boolean } const todosState = sangte([], (prev) => ({ add(todo: Todo) { return prev.push(todo) }, remove(id: number) { return prev.filter((todo) => todo.id !== id) }, toggle(id: number) { const todo = prev.find((todo) => todo.id === id) todo.done = !todo.done }, })) ``` ### 컴포넌트에서 상태 또는 액션을 사용하기 이 라이브러리는 상태나 액션을 여러분의 컴포넌트에서 사용 할 수 있도록 Hook 함수들을 제공합니다. #### useSangte `useSangte`는 `useState`와 비슷하지만, 전역적으로 작동합니다. 이 함수는 상태값과 업데이트 함수를 반환합니다. ```tsx import { sangte, useSangte } from 'sangte' const counterState = sangte(0) function Counter() { const [counter, setCounter] = useSangte(counterState) return (

{counter}

) } export default Counter ``` #### useSangteValue 만약 컴포넌트에서 상태값만 필요로 한다면 `useSangteValue` 를 사용하세요. ```tsx import { useSangteValue } from 'sangte' const counterState = sangte(0) function CounterValue() { const counter = useSangteValue(counterState) return

{counter}

} ``` 상태에서 일부분의 값만 필요로 한다면 두번째 인자에 셀렉터 함수를 넣어서 원하는 부분만 선택할 수 있습니다. 객체 형태로 여러 필드를 선택하게 된다면 기본적으로 shallow compare를 하고 나서 리렌더링을 합니다. 비교 함수는 세번째 인자에 임의 비교 함수를 전달하여 override 할 수 있습니다. ```tsx import { sangte, useSangteValue } from 'sangte' const userState = sangte({ id: 1, name: 'John', email: 'john@email.com' }) function User() { const { name, email } = useSangteValue(userState, (state) => ({ name: state.name, email: state.email, })) return (

{name}

{email}

) } ``` 만약 의존하는 값이 업데이트 되었을 때만 실행되는 memoized 셀렉터를 사용하고 싶으시다면 다음과 같이 읽기 전용 Sangte를 만들어서 사용할 수 있습니다. ```tsx import { sangte } from 'sangte' const todosState = sangte([ { id: 1, text: 'Basic usage', done: true }, { id: 21, text: 'Ready-only sangte', done: false }, ]) const undoneTodosValue = sangte((get) => get(todosState).filter((todo) => !todo.done)) function UndoneTodos() { const undoneTodos = useSangteValue(undoneTodosValue) return
{undoneTodos.length} todos undone.
} ``` #### useSetSangte 만약 컴포넌트에서 상태 업데이트 함수만을 필요로 한다면 `useSetSangte`를 사용하세요. ```tsx import { sangte, useSetSangte } from 'sangte' const counterState = sangte(0) function CounterButtons() { const setCounter = useSetSangte(counterState) return (
) } ``` #### useSangteActions 상태를 만들 때 액션을 정의했다면, `useSangteActions`를 사용하여 액션을 가져와서 호출할 수 있습니다. ```tsx import { sangte, useSangteActions } from 'sangte' const counterState = sangte(0, (prev) => ({ increase() { return prev + 1 }, decreaseBy(amount: number) { return prev - amount }, })) function CounterButtons() { const { increase, decreaseBy } = useSangteActions(counterState) return (
) } ``` #### useResetSangte 상태를 초깃값으로 바꾸고 싶다면 `useResetSangte`를 사용하세요. ```tsx import { sangte, useResetSangte } from 'sangte' const counterState = sangte(0) function Counter() { const [counter, setCounter] = useSangte(counterState) const resetCounter = useResetSangte(counterState) return (

{counter}

) } ``` #### useResetAllSangte 모든 상태를 초기화하고 싶다면 `useResetAllSangte`를 사용하세요. 이 hook은 자식 SangteProvider에도 영향을 미칩니다. ```tsx import { sangte, useResetAllSangte } from 'sangte' const counterState = sangte(0) const textState = sangte('text') function Counter() { const [counter, setCounter] = useSangte(counterState) const [text, setText] = useSangte(textState) const resetAll = useResetAllSangte() return (

{counter} | {text}

) } ``` 전역적으로 모든 상태(모든 부모 SangteProvider에 있는 상태 포함)를 초기화하고 싶다면 함수의 첫번째 인자에 `true`를 넘겨주세요. ```tsx import { sangte, useResetAllSangte } from 'sangte' function RestAll() { const resetAll = useResetAllSangte() return } ``` #### useSangteCallback 콜백 함수 안에서 상태 값을 사용하고 싶지만 상태 값이 바뀔 때마다 컴포넌트가 리렌더링 되는 것을 원하지 않는다면, `useSangteCallback`을 쓰세요. ```tsx import { sangte, useSangteCallback } from 'sangte' const valueState = sangte('hello world!') function ConfirmButton() { // 이 컴포넌트는 valueState가 변경되어도 리렌더링 되지 않습니다. const confirm = useSangteCallback(({ get }) => { const value = get(valueState) console.log(value) // value 값을 가지고 어떠한 작업을 하기.. }, []) return } ``` `useSangteCallback`에서 상태 업데이트 함수나 액션들을 사용할 수 있습니다. ```tsx import { sangte, useSangteCallback } from 'sangte' const counterState = sangte(0, (prev) => ({ add(value: number) { return prev + value }, })) function Counter() { // add 호출 const add2 = useSangteCallback(({ actions }) => { const { add } = actions(counterState) add(2) }, []) // counterState 10000으로 변경 const set10000 = useSangteCallback(({ set }) => { set(counterState, 10000) }, []) return (
) } ``` ================================================ FILE: README.md ================================================ # sangte [![](https://img.shields.io/npm/v/sangte?style=flat-square)](https://www.npmjs.com/package/sangte) [![](https://img.shields.io/bundlephobia/min/sangte?style=flat-square)](https://bundlephobia.com/package/sangte) [![](https://img.shields.io/codecov/c/github/velopert/sangte?style=flat-square)](https://app.codecov.io/gh/velopert/sangte) [English](./README.md) | [한국어](./README-ko.md) Sangte is a state management library for React. This library is inspired by [Redux Toolkit](https://redux-toolkit.js.org/) and [Recoil](https://recoiljs.org/). > Sangte means "state" in Korean. ## Installation To install the library, run the following command: ``` npm install sangte ``` Or if you're using yarn: ``` yarn add sangte ``` ## Why sangte? - Less boilerplate - Rerender only when the state you're using is updated - Easy to use - Allows multiple providers - TypeScript support ## Usage ### First create a state To create a state you need to use the `sangte` function. A state of sangte should have a default value, and actions to update the state. Actions are optional. Sangte uses [immer](https://immerjs.github.io/immer/) internally to update the state. So you can mutate the state directly while keeping the immutability. ```ts import { sangte } from 'sangte' const counterState = sangte(0) const textState = sangte('text') const userState = sangte({ id: 1, name: 'John', email: 'john@email.com' }, (prev) => ({ setName(name: string) { prev.name = name }, setEmail(email: string) { return { ...prev, email, } }, })) interface Todo { id: number text: string done: boolean } const todosState = sangte([], (prev) => ({ add(todo: Todo) { return prev.push(todo) }, remove(id: number) { return prev.filter((todo) => todo.id !== id) }, toggle(id: number) { const todo = prev.find((todo) => todo.id === id) todo.done = !todo.done }, })) ``` ### Use the state or actions from your components The library provides hooks to utilize the state or actions from your components. #### useSangte `useSangte` works like `useState` from React, but it works globally. It returns the state and a setter function to update the state. ```tsx import { sangte, useSangte } from 'sangte' const counterState = sangte(0) function Counter() { const [counter, setCounter] = useSangte(counterState) return (

{counter}

) } export default Counter ``` #### useSangteValue If you only need the value of the state, you can use `useSangteValue`. ```tsx import { useSangteValue } from 'sangte' const counterState = sangte(0) function CounterValue() { const counter = useSangteValue(counterState) return

{counter}

} ``` If you want to select a part of the state, you can pass selector function as second argument. If you select multiple fields, the component will rerender after shallow comparison. You can override the comparison function by passing a custom equality function to third argument. ```tsx import { sangte, useSangteValue } from 'sangte' const userState = sangte({ id: 1, name: 'John', email: 'john@email.com' }) function User() { const { name, email } = useSangteValue(userState, (state) => ({ name: state.name, email: state.email, })) return (

{name}

{email}

) } ``` If you want to use a memoized selector that is prcessed only when its dependencies update, you can create a read-only Sangte as below. ```tsx import { sangte } from 'sangte' const todosState = sangte([ { id: 1, text: 'Basic usage', done: true }, { id: 21, text: 'Ready-only sangte', done: false }, ]) const undoneTodosValue = sangte((get) => get(todosState).filter((todo) => !todo.done)) function UndoneTodos() { const undoneTodos = useSangteValue(undoneTodosValue) return
{undoneTodos.length} todos undone.
} ``` #### useSetSangte If you only need the updater function of the state, you can use `useSetSangte`. ```tsx import { sangte, useSetSangte } from 'sangte' const counterState = sangte(0) function CounterButtons() { const setCounter = useSetSangte(counterState) return (
) } ``` #### useSangteActions If you have defined actions for the state, you can use `useSangteActions` to get the actions. ```tsx import { sangte, useSangteActions } from 'sangte' const counterState = sangte(0, (prev) => ({ increase() { return prev + 1 }, decreaseBy(amount: number) { return prev - amount }, })) function CounterButtons() { const { increase, decreaseBy } = useSangteActions(counterState) return (
) } ``` #### useResetSangte If you want to reset the state to its default value, you can use `useResetSangte`. ```tsx import { sangte, useResetSangte } from 'sangte' const counterState = sangte(0) function Counter() { const [counter, setCounter] = useSangte(counterState) const resetCounter = useResetSangte(counterState) return (

{counter}

) } ``` #### useResetAllSangte If you want to reset all the states to their default values, you can use `useResetAllSangte`. This hook also resets sangte inside the nested providers. ```tsx import { sangte, useResetAllSangte } from 'sangte' const counterState = sangte(0) const textState = sangte('text') function Counter() { const [counter, setCounter] = useSangte(counterState) const [text, setText] = useSangte(textState) const resetAll = useResetAllSangte() return (

{counter} | {text}

) } ``` If you want to reset the states globally (including all parent providers), you can pass `true` as first argument. ```tsx import { sangte, useResetAllSangte } from 'sangte' function RestAll() { const resetAll = useResetAllSangte() return } ``` #### useSangteCallback If you want to use the state in a callback but you do not want to rerender the component as the state changes, you can use `useSangteCallback`. ```tsx import { sangte, useSangteCallback } from 'sangte' const valueState = sangte('hello world!') function ConfirmButton() { // this component won't rerender even when valueState changes const confirm = useSangteCallback(({ get }) => { const value = get(valueState) console.log(value) // do something with value.. }, []) return } ``` You can also use the setter function or actions with `useSangteCallback`. ```tsx import { sangte, useSangteCallback } from 'sangte' const counterState = sangte(0, (prev) => ({ add(value: number) { return prev + value }, })) function Counter() { // calls add action const add2 = useSangteCallback(({ actions }) => { const { add } = actions(counterState) add(2) }, []) // sets counterState to 10000 const set10000 = useSangteCallback(({ set }) => { set(counterState, 10000) }, []) return (
) } ``` ## Recipe ### Using multiple providers ### Inheriting state from parent provider ### Server side rendering > Docs are still in progress. If you have any questions, please open an issue. ================================================ FILE: babel.config.js ================================================ module.exports = (api, targets) => { // https://babeljs.io/docs/en/config-files#config-function-api const isTestEnv = api.env('test') return { babelrc: false, ignore: ['./node_modules'], presets: [ [ '@babel/preset-env', { loose: true, modules: isTestEnv ? 'commonjs' : false, targets: isTestEnv ? { node: 'current' } : targets, }, ], ], plugins: [ [ '@babel/plugin-transform-react-jsx', { runtime: 'automatic', }, ], ['@babel/plugin-transform-typescript', { isTSX: true }], ], } } ================================================ FILE: examples/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: examples/index.html ================================================ Vite + React + TS
================================================ FILE: examples/package.json ================================================ { "name": "vite-project", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "sangte": "^0.1.18" }, "devDependencies": { "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", "@vitejs/plugin-react": "^2.0.1", "typescript": "^4.6.4", "vite": "^3.0.7" } } ================================================ FILE: examples/src/App.css ================================================ .samples { display: flex; flex-wrap: wrap; } .samples > div { display: flex; align-items:center; justify-content: center; border: 1px solid black; width: 25%; padding: 16px; } ================================================ FILE: examples/src/App.tsx ================================================ import './App.css' import Counter from './components/Counter' import Initialize from './components/Initialize' import Hydrate from './components/Hydrate' import Selector from './components/Selector' import MultiProviders from './components/MultiProviders' import Inherit from './components/Inherit' import GlobalState from './components/GlobalState' import MemoizedSelector from './components/MemoizedSelector' import Todos from './components/Todos' import VisibleToggle from './components/VisibleToggle' import MemoryLeakTester from './components/MemoryLeakTester' function App() { return (
) } export default App ================================================ FILE: examples/src/components/Counter.tsx ================================================ import { sangte, useResetSangte, useSangteActions, useSangteValue } from 'sangte' const counterState = sangte(0, (prev) => ({ increase() { return prev + 1 }, decrease(amount: number) { return prev - amount }, })) function Counter() { const counter = useSangteValue(counterState) const actions = useSangteActions(counterState) const reset = useResetSangte(counterState) return (

{counter}

) } export default Counter ================================================ FILE: examples/src/components/GlobalState.tsx ================================================ import { SangteProvider, useSangteActions } from 'sangte' import { useSangteValue } from 'sangte' import { sangte } from 'sangte' const globalCounterState = sangte( 0, (prev) => ({ increase() { return prev + 1 }, }), { global: true, } ) function Counter() { const counter = useSangteValue(globalCounterState) const actions = useSangteActions(globalCounterState) return (

{counter}

) } function GlobalState() { return (
) } export default GlobalState ================================================ FILE: examples/src/components/Hydrate.tsx ================================================ import { sangte, SangteProvider, useSangteValue } from 'sangte' const numberState = sangte(0, { key: 'number' }) const textState = sangte('hello world', { key: 'text' }) function Values() { const number = useSangteValue(numberState) const text = useSangteValue(textState) return (
number: {number}
text: {text}
) } function Hydrate() { return ( ) } export default Hydrate ================================================ FILE: examples/src/components/Inherit.tsx ================================================ import { SangteProvider, useSangteActions } from 'sangte' import { useSangteValue } from 'sangte' import { sangte } from 'sangte' const counterState = sangte(0, (prev) => ({ increase() { return prev + 1 }, })) function Counter() { const counter = useSangteValue(counterState) const actions = useSangteActions(counterState) return (

{counter}

) } function Inherit() { return (
) } export default Inherit ================================================ FILE: examples/src/components/Initialize.tsx ================================================ import React from 'react' import { sangte, SangteProvider, useSangteValue } from 'sangte' const textState = sangte('Default text') function Text() { const text = useSangteValue(textState) return
{text}
} function Initialize() { return ( { set(textState, 'Hello World!') }} > ) } export default Initialize ================================================ FILE: examples/src/components/MemoizedSelector.tsx ================================================ import { useState } from 'react' import { resangte, sangte, useSangteActions, useSangteValue } from 'sangte' import { todosState } from './Todos' const uncompletedTodosValue = resangte((get) => { console.log('filtering..') return get(todosState).filter((todo) => !todo.completed) }) function MemoizedSelector() { const uncompletedTodos = useSangteValue(uncompletedTodosValue) const actions = useSangteActions(todosState) const [value, setValue] = useState(0) return (
{uncompletedTodos.map((todo) => (
{ actions.toggle(todo.id) }} > {todo.text}
))}
{value}
) } export default MemoizedSelector ================================================ FILE: examples/src/components/MemoryLeakTester.tsx ================================================ import { useEffect, useRef, useState } from 'react' import { resangte, sangte, SangteProvider, useSangteActions, useSangteValue } from 'sangte' const array = new Array(1000).fill(0).map((_, i) => ({ id: i, done: false })) const itemsState = sangte(array, (prev) => ({ work(id: number) { const item = prev.find((item) => item.id === id) if (!item) return item.done = true }, })) const doneItemsValue = resangte((get) => get(itemsState).filter((item) => item.done)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function Work({ onHide }: { onHide(): void }) { const called = useRef(false) const doneItems = useSangteValue(doneItemsValue) const { work } = useSangteActions(itemsState) useEffect(() => { if (called.current) return called.current = true const random = Math.floor(Math.random() * 10) const loop = async () => { for (let i = 0; i < random; i++) { work(i) console.log('workign!') await sleep(1) } onHide() } loop() }, []) return
{doneItems.length} items done!
} function Wrapper() { const [visible, setVisible] = useState(true) useEffect(() => { if (!visible) { setVisible(true) } }, [visible]) if (!visible) return null return ( { setVisible(false) }} /> ) } function MemoryLeakTester() { return } export default MemoryLeakTester ================================================ FILE: examples/src/components/MultiProviders.tsx ================================================ import { SangteProvider, useSangteActions } from 'sangte' import { useSangteValue } from 'sangte' import { sangte } from 'sangte' const counterState = sangte(0, (prev) => ({ increase() { return prev + 1 }, })) function Counter() { const counter = useSangteValue(counterState) const actions = useSangteActions(counterState) return (

{counter}

) } function MultiProviders() { return (
) } export default MultiProviders ================================================ FILE: examples/src/components/Selector.tsx ================================================ import { sangte, useSangteActions, useSangteValue } from 'sangte' const userState = sangte( { id: 1, username: 'velopert', email: 'public.velopert@gmail.com', isActive: false, }, (prev) => ({ toggleActive() { prev.isActive = !prev.isActive }, }) ) function Value() { const { username, email } = useSangteValue(userState, (state) => ({ username: state.username, email: state.email, })) return (
{username}
{email}
) } function Toggle() { const { toggleActive } = useSangteActions(userState) return } function Active() { const isActive = useSangteValue(userState, (state) => state.isActive) return
{isActive ? 'active' : 'inactive'}
} function Selector() { return (
) } export default Selector ================================================ FILE: examples/src/components/Todos.tsx ================================================ import { sangte, useSangteActions, useSangteValue } from 'sangte' export const todosState = sangte( [ { id: 1, text: 'Learn React', completed: true, }, { id: 2, text: 'Learn Sangte', completed: false, }, { id: 3, text: 'Learn React Router', completed: false, }, ], (prev) => ({ toggle(id: number) { const todo = prev.find((todo) => todo.id === id) if (!todo) return todo.completed = !todo.completed }, }) ) function Todos() { const todos = useSangteValue(todosState) const actions = useSangteActions(todosState) return (
{todos.map((todo) => (
{ actions.toggle(todo.id) }} > {todo.text}
))}
) } export default Todos ================================================ FILE: examples/src/components/VisibleToggle.tsx ================================================ import React, { useState } from 'react' function VisibleToggle({ children }: { children: React.ReactNode }) { const [visible, setVisible] = useState(false) return (
{visible ? children : null}
) } export default VisibleToggle ================================================ FILE: examples/src/index.css ================================================ :root { font-family: Inter, Avenir, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 24px; font-weight: 400; color-scheme: light dark; color: rgba(255, 255, 255, 0.87); background-color: #242424; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -webkit-text-size-adjust: 100%; } a { font-weight: 500; color: #646cff; text-decoration: inherit; } a:hover { color: #535bf2; } body { margin: 0; display: flex; } h1 { font-size: 3.2em; line-height: 1.1; } button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; background-color: #1a1a1a; cursor: pointer; transition: border-color 0.25s; } button:hover { border-color: #646cff; } button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } @media (prefers-color-scheme: light) { :root { color: #213547; background-color: #ffffff; } a:hover { color: #747bff; } button { background-color: #f9f9f9; } } ================================================ FILE: examples/src/main.tsx ================================================ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import './index.css' ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ) ================================================ FILE: examples/src/vite-env.d.ts ================================================ /// ================================================ FILE: examples/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "module": "ESNext", "moduleResolution": "Node", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/vite.config.ts ================================================ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()] }) ================================================ FILE: index.html ================================================ Vite App
================================================ FILE: jest.config.ts ================================================ /* * For a detailed explanation regarding each configuration property and type check, visit: * https://jestjs.io/docs/configuration */ export default { setupFilesAfterEnv: ['/src/setupTest.ts'], // All imported modules in your tests should be mocked automatically // automock: false, // Stop running tests after `n` failures // bail: 0, // The directory where Jest should store its cached dependency information // cacheDirectory: "/tmp/jest_rs", // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, // Indicates whether the coverage information should be collected while executing the test collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected // collectCoverageFrom: undefined, // The directory where Jest should output its coverage files coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection // coveragePathIgnorePatterns: [ // "/node_modules/" // ], // Indicates which provider should be used to instrument code for coverage coverageProvider: 'v8', // A list of reporter names that Jest uses when writing coverage reports // coverageReporters: [ // "json", // "text", // "lcov", // "clover" // ], // An object that configures minimum threshold enforcement for coverage results // coverageThreshold: undefined, // A path to a custom dependency extractor // dependencyExtractor: undefined, // Make calling deprecated APIs throw helpful error messages // errorOnDeprecated: false, // The default configuration for fake timers // fakeTimers: { // "enableGlobally": false // }, // Force coverage collection from ignored files using an array of glob patterns // forceCoverageMatch: [], // A path to a module which exports an async function that is triggered once before all test suites // globalSetup: undefined, // A path to a module which exports an async function that is triggered once after all test suites // globalTeardown: undefined, // A set of global variables that need to be available in all test environments // globals: {}, // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. // maxWorkers: "50%", // An array of directory names to be searched recursively up from the requiring module's location // moduleDirectories: [ // "node_modules" // ], // An array of file extensions your modules use // moduleFileExtensions: [ // "js", // "mjs", // "cjs", // "jsx", // "ts", // "tsx", // "json", // "node" // ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module // moduleNameMapper: {}, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], // Activates notifications for test results // notify: false, // An enum that specifies notification mode. Requires { notify: true } // notifyMode: "failure-change", // A preset that is used as a base for Jest's configuration // preset: undefined, // Run tests from one or more projects // projects: undefined, // Use this configuration option to add custom reporters to Jest // reporters: undefined, // Automatically reset mock state before every test // resetMocks: false, // Reset the module registry before running each individual test // resetModules: false, // A path to a custom resolver // resolver: undefined, // Automatically restore mock state and implementation before every test // restoreMocks: false, // The root directory that Jest should scan for tests and modules within // rootDir: undefined, // A list of paths to directories that Jest should use to search for files in // roots: [ // "" // ], // Allows you to use a custom runner instead of Jest's default test runner // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, // A list of paths to snapshot serializer modules Jest should use for snapshot testing // snapshotSerializers: [], // The test environment that will be used for testing testEnvironment: 'jsdom', // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, // Adds a location field to test results // testLocationInResults: false, // The glob patterns Jest uses to detect test files // testMatch: [ // "**/__tests__/**/*.[jt]s?(x)", // "**/?(*.)+(spec|test).[tj]s?(x)" // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ // "/node_modules/" // ], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], // This option allows the use of a custom results processor // testResultsProcessor: undefined, // This option allows use of a custom test runner // testRunner: "jest-circus/runner", // A map from regular expressions to paths to transformers // transform: undefined, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // transformIgnorePatterns: [ // "/node_modules/", // "\\.pnp\\.[^\\/]+$" // ], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, // Indicates whether each individual test should be reported during the run // verbose: undefined, // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode // watchPathIgnorePatterns: [], // Whether to use watchman for file crawling // watchman: true, } ================================================ FILE: package.json ================================================ { "name": "sangte", "description": "Sangte is a fancy React state management library.", "private": false, "version": "0.1.26", "main": "dist/index.js", "module": "dist/index.mjs", "typings": "dist/index.d.ts", "scripts": { "build": "rollup -c", "test": "jest" }, "dependencies": { "immer": "^9.0.15", "use-sync-external-store": "^1.2.0" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "^7.18.10", "@babel/plugin-transform-typescript": "^7.18.12", "@babel/preset-env": "^7.18.10", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.3.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/use-sync-external-store": "^0.0.3", "esbuild": "^0.15.5", "jest": "^29.0.1", "jest-environment-jsdom": "^29.0.1", "jsdom": "^20.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "rollup": "^2.75.7", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "^4.9.1", "ts-node": "^10.9.1", "typescript": "^4.6.3" }, "peerDependencies": { "react": ">=16.8" }, "files": [ "/dist" ], "repository": { "type": "git", "url": "git+https://github.com/velopert/sangte" }, "author": "velopert", "license": "MIT", "homepage": "https://github.com/velopert/sangte" } ================================================ FILE: rollup.config.js ================================================ import dts from 'rollup-plugin-dts' import esbuild from 'rollup-plugin-esbuild' const name = require('./package.json').main.replace(/\.js$/, '') const bundle = (config) => ({ ...config, input: 'src/index.ts', external: (id) => !/^[./]/.test(id), }) export default [ bundle({ plugins: [ esbuild({ jsx: 'automatic', }), ], output: [ { file: `${name}.js`, format: 'cjs', sourcemap: true, }, { file: `${name}.mjs`, format: 'es', sourcemap: true, }, ], }), bundle({ plugins: [dts()], output: { file: `${name}.d.ts`, format: 'es', }, }), ] ================================================ FILE: src/contexts/SangteProvider.tsx ================================================ import { createContext, useCallback, useContext, useEffect, useRef } from 'react' import { Sangte } from '../lib/sangte' import { SangteInitializer } from '../lib/SangteInitializer' import { SangteManager } from '../lib/SangteManager' const SangteContext = createContext(null) interface SangteProviderProps { children: React.ReactNode inheritSangtes?: Sangte[] initialize?: (params: { set: (sangte: Sangte, value: T) => void }) => void dehydratedState?: Record } export function SangteProvider({ children, inheritSangtes, initialize, dehydratedState, }: SangteProviderProps) { const parent = useContext(SangteContext) const managerRef = useRef(null) const initialized = useRef(false) const initializeProvider = useCallback(() => { if (initialized.current) return const manager = managerRef.current ?? new SangteManager() managerRef.current = manager if (parent) { manager.parent = parent parent.children.add(manager) } manager.dehydratedState = dehydratedState if (inheritSangtes) { if (!parent) { throw new Error( 'Cannot inherit sangtes from default SangteManager. Please wrap your app with SangteProvider.' ) } manager.inherit(inheritSangtes) } if (initialize) { initialize({ set: manager.initializer.set.bind(manager.initializer), }) manager.initializer.initialize() } initialized.current = true }, []) initializeProvider() /** * Reinitialization needed because useEffect runs twice on component mount in future React (and development mode) */ useEffect(() => { initializeProvider() return () => { initialized.current = false if (parent && managerRef.current) { parent.children.delete(managerRef.current) } } }, [initializeProvider]) return {children} } /** * Default manager used when SangteProvider is not used. * To */ const defaultManager = new SangteManager(true) export function useSangteManager() { const manager = useContext(SangteContext) if (!manager) { return defaultManager } return manager } ================================================ FILE: src/contexts/__tests__/SangteProvider.test.tsx ================================================ import { fireEvent, render, screen } from '@testing-library/react' import { SangteProvider } from '../SangteProvider' import { sangte } from '../../lib' import { useSangte, useSangteValue } from '../../hooks' describe('SangteProvider', () => { it('should render children', () => { render(
hello
) screen.getByText('hello') }) it('should initializeState', () => { const state = sangte(0) function Child() { const value = useSangteValue(state) return
{value}
} render( { set(state, 10) }} > ) expect(screen.getByTestId('value')).toHaveTextContent('10') }) it('should use closest provider', () => { const state = sangte(0) function Child({ testId }: { testId: string }) { const [value, setState] = useSangte(state) return (
{value}
) } render( { set(state, 10) }} > ) expect(screen.getByTestId('parent')).toHaveTextContent('0') expect(screen.getByTestId('child')).toHaveTextContent('10') fireEvent.click(screen.getByTestId('button-child')) expect(screen.getByTestId('parent')).toHaveTextContent('0') expect(screen.getByTestId('child')).toHaveTextContent('15') }) it('should inherit state', () => { const state = sangte(0) function Child({ testId }: { testId: string }) { const [value, setState] = useSangte(state) return (
{value}
) } render( { set(state, 10) }} inheritSangtes={[state]} > ) expect(screen.getByTestId('parent')).toHaveTextContent('10') expect(screen.getByTestId('child')).toHaveTextContent('10') fireEvent.click(screen.getByTestId('button-child')) expect(screen.getByTestId('parent')).toHaveTextContent('15') expect(screen.getByTestId('child')).toHaveTextContent('15') }) it('should dehydrate', () => { const counterState = sangte(0, { key: 'counter' }) const textState = sangte('hello world', { key: 'text' }) function Child() { const counter = useSangteValue(counterState) const text = useSangteValue(textState) return (
{counter}
{text}
) } render( ) expect(screen.getByTestId('counter')).toHaveTextContent('10') expect(screen.getByTestId('text')).toHaveTextContent('bye world') }) it('cannot inherit from default SangteManager', () => { const state = sangte(0) expect(() => { render(
hello
) }).toThrowError() }) }) ================================================ FILE: src/contexts/index.ts ================================================ export * from './SangteProvider' ================================================ FILE: src/hooks/__tests__/useResetAllSangte.test.tsx ================================================ import { fireEvent, render, screen } from '@testing-library/react' import { act, renderHook } from '@testing-library/react-hooks' import { SangteProvider } from '../../contexts' import { sangte } from '../../lib' import { useResetAllSangte } from '../useResetAllSangte' import { useSangte } from '../useSangte' describe('useResetAllSangte', () => { it('resets to initialState', () => { const state = sangte(0) const anotherState = sangte(1) const { result } = renderHook(() => useSangte(state)) const { result: result2 } = renderHook(() => useSangte(anotherState)) act(() => { result.current[1](5) result2.current[1](6) }) expect(result.current[0]).toBe(5) expect(result2.current[0]).toBe(6) const { result: { current: resetAll }, } = renderHook(() => useResetAllSangte()) act(() => { resetAll() }) expect(result.current[0]).toBe(0) expect(result2.current[0]).toBe(1) }) it('should reset children only', () => { const state = sangte(0) function Child({ testId }: { testId: string }) { const [value, setState] = useSangte(state) const resetAll = useResetAllSangte() return (
{value}
) } render( { set(state, 3) }} > { set(state, 10) }} > { set(state, 5) }} > ) expect(screen.getByTestId('parent')).toHaveTextContent('3') expect(screen.getByTestId('child')).toHaveTextContent('10') expect(screen.getByTestId('grandchild')).toHaveTextContent('5') fireEvent.click(screen.getByTestId('button-reset-child')) expect(screen.getByTestId('parent')).toHaveTextContent('3') expect(screen.getByTestId('child')).toHaveTextContent(/^0$/) expect(screen.getByTestId('grandchild')).toHaveTextContent(/^0$/) }) it('should reset globally', () => { const state = sangte(0) function Child({ testId }: { testId: string }) { const [value, setState] = useSangte(state) const resetAll = useResetAllSangte() return (
{value}
) } render( { set(state, 3) }} > { set(state, 10) }} > { set(state, 5) }} > ) expect(screen.getByTestId('parent')).toHaveTextContent('3') expect(screen.getByTestId('child')).toHaveTextContent('10') expect(screen.getByTestId('grandchild')).toHaveTextContent('5') fireEvent.click(screen.getByTestId('button-reset-grandchild')) expect(screen.getByTestId('parent')).toHaveTextContent(/^0$/) expect(screen.getByTestId('child')).toHaveTextContent(/^0$/) expect(screen.getByTestId('grandchild')).toHaveTextContent(/^0$/) }) }) ================================================ FILE: src/hooks/__tests__/useResetSangte.test.ts ================================================ import { act, renderHook } from '@testing-library/react-hooks' import { sangte } from '../../lib' import { useResetSangte } from '../useResetSangte' import { useSangte } from '../useSangte' describe('useResetSangte', () => { it('resets to initialState', () => { const state = sangte(0) const { result } = renderHook(() => useSangte(state)) act(() => { result.current[1](5) }) expect(result.current[0]).toBe(5) const { result: { current: reset }, } = renderHook(() => useResetSangte(state)) act(() => { reset() }) expect(result.current[0]).toBe(0) }) }) ================================================ FILE: src/hooks/__tests__/useSangte.test.ts ================================================ import { sangte } from '../../lib' import { useSangte } from '..' import { renderHook, act } from '@testing-library/react-hooks' describe('useSangte', () => { it('can be called', () => { const counterState = sangte(0) const { result } = renderHook(() => useSangte(counterState)) expect(result.current[0]).toBe(0) expect(typeof result.current[1]).toBe('function') }) it('can update the state', () => { const counterState = sangte(0) const { result } = renderHook(() => useSangte(counterState)) act(() => { result.current[1](10) }) expect(result.current[0]).toBe(10) act(() => { result.current[1]((prev) => prev + 1) }) expect(result.current[0]).toBe(11) }) }) ================================================ FILE: src/hooks/__tests__/useSangteActions.test.ts ================================================ import { renderHook, act } from '@testing-library/react-hooks' import { sangte } from '../../lib' import { useSangteActions } from '../useSangteActions' import { useSangteValue } from '../useSangteValue' describe('useSangteActions', () => { it('returns actions', () => { const state = sangte(0, (prev) => ({ increase() { return prev + 1 }, decrease() { return prev - 1 }, })) const { result } = renderHook(() => useSangteActions(state)) expect(typeof result.current.decrease).toBe('function') }) it('updates value according to actions', () => { const state = sangte(0, (prev) => ({ increase() { return prev + 1 }, decrease() { return prev - 1 }, })) const { result: { current: actions }, } = renderHook(() => useSangteActions(state)) const { result } = renderHook(() => useSangteValue(state)) expect(result.current).toBe(0) act(() => { actions.increase() }) expect(result.current).toBe(1) }) it('throws error when action not defined', () => { const state = sangte(0) expect(() => { const { result: { current: actions }, // @ts-ignore } = renderHook(() => useSangteActions(state)) }).toThrowError() }) }) ================================================ FILE: src/hooks/__tests__/useSangteCallback.test.ts ================================================ import { act, renderHook } from '@testing-library/react-hooks' import { sangte } from '../../lib' import { useSangteCallback } from '../useSangteCallback' import { useSangteValue } from '../useSangteValue' describe('useSangteCallback', () => { it('get value', () => { const state = sangte(5) let value const { result } = renderHook(() => useSangteCallback(({ get }) => { value = get(state) }, []) ) act(() => { result.current() }) expect(value).toBe(5) }) it('set value', () => { const state = sangte(0) const { result: sangteValue } = renderHook(() => useSangteValue(state)) expect(sangteValue.current).toBe(0) const { result } = renderHook(() => useSangteCallback(({ set }) => { set(state, 100) }, []) ) act(() => { result.current() }) expect(sangteValue.current).toBe(100) }) it('update value with action', () => { const state = sangte(0, (prev) => ({ increase() { return prev + 1 }, })) const { result: sangteValue } = renderHook(() => useSangteValue(state)) expect(sangteValue.current).toBe(0) const { result } = renderHook(() => useSangteCallback(({ actions }) => { const { increase } = actions(state) increase() }, []) ) act(() => { result.current() }) expect(sangteValue.current).toBe(1) }) it('throws error when action not defined ', () => { const state = sangte(0) const { result } = renderHook(() => useSangteCallback(({ actions }) => { // @ts-ignore actions(state) }, []) ) expect(() => { act(() => { result.current() }) }).toThrowError() }) }) ================================================ FILE: src/hooks/__tests__/useSangteStore.test.ts ================================================ import { renderHook } from '@testing-library/react-hooks' import { sangte } from '../../lib' import { useSangteStore } from '../useSangteStore' describe('useSangteStore', () => { it('returns store', () => { const state = sangte(0) const { result } = renderHook(() => useSangteStore(state)) expect(result.current).toHaveProperty('getState') }) }) ================================================ FILE: src/hooks/__tests__/useSangteValue.test.ts ================================================ import { renderHook } from '@testing-library/react-hooks' import { sangte } from '../../lib' import { useSangteValue } from '../useSangteValue' describe('useSangteValue', () => { it('returns value', () => { const state = sangte(0) const { result } = renderHook(() => useSangteValue(state)) expect(result.current).toBe(0) }) it('selects value', () => { const state = sangte({ count: 0 }) const { result } = renderHook(() => useSangteValue(state, (state) => state.count)) expect(result.current).toBe(0) }) it('selects memoized value', () => { const numbersState = sangte([0, 1, 2, 3, 4, 5]) const filteredNumbersState = sangte((get) => get(numbersState).filter((number) => number > 2)) const { result } = renderHook(() => useSangteValue(filteredNumbersState)) expect(result.current).toEqual([3, 4, 5]) }) it('selects multiple fields', () => { const state = sangte({ count: 0, name: 'foo' }) const { result } = renderHook(() => useSangteValue(state, (state) => ({ count: state.count, name: state.name, })) ) expect(result.current).toEqual({ count: 0, name: 'foo' }) }) }) ================================================ FILE: src/hooks/__tests__/useSetSangte.test.ts ================================================ import { act, renderHook } from '@testing-library/react-hooks' import { sangte } from '../../lib' import { useSangteValue } from '../useSangteValue' import { useSetSangte } from '../useSetSangte' describe('useSetSangte', () => { it('updates value', () => { const state = sangte(0) const { result } = renderHook(() => useSangteValue(state)) expect(result.current).toBe(0) const { result: { current }, } = renderHook(() => useSetSangte(state)) act(() => { current(5) }) expect(result.current).toBe(5) }) }) ================================================ FILE: src/hooks/index.ts ================================================ export * from './useResetSangte' export * from './useSangte' export * from './useSangteActions' export * from './useSangteStore' export * from './useSangteValue' export * from './useSetSangte' export * from './useSangteCallback' export * from './useResetAllSangte' ================================================ FILE: src/hooks/useResetAllSangte.ts ================================================ import { useCallback } from 'react' import { useSangteManager } from '../contexts' /** * Resets every sangte registered to the current SangteProvider. */ export function useResetAllSangte() { const sangteManager = useSangteManager() return useCallback((global?: boolean) => sangteManager.reset(global), [sangteManager]) } ================================================ FILE: src/hooks/useResetSangte.ts ================================================ import { Sangte } from '../lib/sangte' import { useSangteStore } from './useSangteStore' export function useResetSangte(sangte: Sangte) { const store = useSangteStore(sangte) return store.reset } ================================================ FILE: src/hooks/useSangte.ts ================================================ import { useSyncExternalStore } from 'use-sync-external-store/shim' import { Sangte } from '../lib/sangte' import { useSangteStore } from './useSangteStore' export function useSangte(sangte: Sangte) { const store = useSangteStore(sangte) const state = useSyncExternalStore(store.subscribe, store.getState, store.getState) return [state, store.setState] as const } ================================================ FILE: src/hooks/useSangteActions.ts ================================================ import { ActionRecord, Sangte } from '../lib/sangte' import { useSangteStore } from './useSangteStore' export function useSangteActions>(sangte: Sangte) { const store = useSangteStore(sangte) if (!store.actions) { throw new Error('This sangte does not have createActions') } return store.actions } ================================================ FILE: src/hooks/useSangteCallback.ts ================================================ import { DependencyList, useCallback } from 'react' import { useSangteManager } from '../contexts/SangteProvider' import { ActionRecord, Sangte } from '../lib/sangte' interface SanteCallbackParams { get: (sangte: Sangte) => T set: (sangte: Sangte, value: T) => void actions: >(sangte: Sangte) => A } export function useSangteCallback( callback: (params: SanteCallbackParams) => void, deps: DependencyList ) { const sangteManager = useSangteManager() return useCallback(() => { callback({ get: (sangte) => sangteManager.get(sangte).getState(), set: (sangte, value) => sangteManager.get(sangte).setState(value), actions: >(sangte: Sangte) => { const actions = sangteManager.get(sangte).actions if (!actions) throw new Error('This sangte does not have createActions') return actions }, }) }, [...deps, sangteManager]) } ================================================ FILE: src/hooks/useSangteStore.ts ================================================ import { useSangteManager } from '../contexts/SangteProvider' import { Sangte } from '../lib/sangte' export function useSangteStore(sangte: Sangte) { const sangteManager = useSangteManager() const store = sangteManager.get(sangte) return store } ================================================ FILE: src/hooks/useSangteValue.ts ================================================ import { useSyncExternalStore } from 'use-sync-external-store/shim' import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector' import { Sangte } from '../lib/sangte' import { shallowEqual } from '../lib/shallowEqual' import { useSangteStore } from './useSangteStore' export function useSangteValue(sangte: Sangte): T export function useSangteValue(sangte: Sangte, selector: (state: T) => S): S export function useSangteValue( sangte: Sangte, selector?: (state: T) => S, compare?: (a: S, b: S) => boolean ) { const store = useSangteStore(sangte) if (!selector) { const state = useSyncExternalStore(store.subscribe, store.getState, store.getState) return state } const state = useSyncExternalStoreWithSelector( store.subscribe, store.getState, store.getState, selector, compare ?? shallowEqual ) return state } ================================================ FILE: src/hooks/useSetSangte.ts ================================================ import { Sangte } from '../lib/sangte' import { useSangteStore } from './useSangteStore' export function useSetSangte(sangte: Sangte) { const store = useSangteStore(sangte) return store.setState } ================================================ FILE: src/index.ts ================================================ export * from './contexts' export * from './hooks' export * from './lib' ================================================ FILE: src/lib/SangteInitializer.ts ================================================ import { Sangte } from './sangte' import { SangteManager } from './SangteManager' export class SangteInitializer { private sangteInitialStateMap = new Map, any>() constructor(private manager: SangteManager) {} public set(sangte: Sangte, initialState: T) { this.sangteInitialStateMap.set(sangte, initialState) } public initialize() { this.sangteInitialStateMap.forEach((initialState, sangte) => { const instance = this.manager.get(sangte) if (sangte.config.isResangte) return instance.setState(initialState) }) } } ================================================ FILE: src/lib/SangteManager.ts ================================================ import { Sangte, SangteInstance } from './sangte' import { SangteInitializer } from './SangteInitializer' export class SangteManager { private instanceMap = new Map, SangteInstance>() public initializer: SangteInitializer = new SangteInitializer(this) public children = new Set() constructor(public isDefault: boolean = false) {} public get(sangte: Sangte): SangteInstance { const manager = sangte.config.global ? this.getRootSangteManager() : this const instance = manager.instanceMap.get(sangte) if (instance) { return instance } const newInstance = sangte(this) if (sangte.config.key && this.dehydratedState && !sangte.config.isResangte) { const selected = this.dehydratedState[sangte.config.key] if (selected) { newInstance.setState(selected) } } manager.instanceMap.set(sangte, newInstance) return newInstance } public parent: SangteManager | null = null public dehydratedState?: Record | null public getRootSangteManager(): SangteManager { let manager: SangteManager | undefined = this while (manager.parent) { manager = manager.parent } return manager } public inherit(sangtes: Sangte[]) { const parent = this.parent if (!parent) return sangtes.forEach((sangte) => { const sangteInstance = parent.get(sangte) this.instanceMap.set(sangte, sangteInstance) }) } /** resets all sangte registered to this SangteManager */ public reset(global: boolean = false) { if (global) { this.getRootSangteManager().reset() return } Array.from(this.instanceMap.entries()).forEach(([sangte, instance]) => { if (!sangte.config.isResangte) { instance.reset() } }) this.children.forEach((child) => child.reset()) } } ================================================ FILE: src/lib/__tests__/SangteManager.test.ts ================================================ import { sangte } from '../sangte' import { SangteManager } from '../SangteManager' describe('SangteManager', () => { it('creates instance', () => { const manager = new SangteManager() expect(manager).toBeInstanceOf(SangteManager) }) it('gets root manager', () => { const grandParent = new SangteManager() const parent = new SangteManager() parent.parent = grandParent const child = new SangteManager() child.parent = parent expect(child.getRootSangteManager()).toBe(grandParent) }) it('resets all sangte', () => { const counterState = sangte(0) const textState = sangte('') const manager = new SangteManager() manager.get(counterState).setState(1) manager.get(textState).setState('hello') expect(manager.get(counterState).getState()).toBe(1) expect(manager.get(textState).getState()).toBe('hello') manager.reset() expect(manager.get(counterState).getState()).toBe(0) expect(manager.get(textState).getState()).toBe('') }) }) ================================================ FILE: src/lib/__tests__/sangte.test.ts ================================================ import { sangte } from '../sangte' import { SangteManager } from '../SangteManager' describe('sangte', () => { it('can be created', () => { const create = sangte({ count: 0 }) const store = create() expect(store.getState().count).toBe(0) }) it('can be updated with setState', () => { const create = sangte({ count: 0 }) const store = create() store.setState({ count: 1 }) expect(store.getState().count).toBe(1) store.setState((prev) => ({ count: prev.count + 5 })) expect(store.getState().count).toBe(6) }) it('calls subscriptions when updated', () => { const create = sangte({ count: 0 }) const store = create() const callback = jest.fn() const callback2 = jest.fn() store.subscribe(callback) store.subscribe(callback2) store.setState({ count: 1 }) expect(callback).toBeCalled() expect(callback2).toBeCalled() }) it('unsubcribes properly', () => { const create = sangte({ count: 0 }) const store = create() const callback = jest.fn() const unsubcribe = store.subscribe(callback) store.setState({ count: 1 }) expect(callback).toBeCalled() unsubcribe() callback.mockReset() store.setState({ count: 2 }) expect(callback).not.toBeCalled() }) it('calls actions properly', () => { const create = sangte({ count: 0 }, (prev) => ({ increase() { prev.count += 1 }, decreaseBy(amount: number) { return { ...prev, count: prev.count - amount, } }, })) const store = create() const callback = jest.fn() store.subscribe(callback) expect(store.getState().count).toBe(0) store.actions?.increase() expect(store.getState().count).toBe(1) store.actions?.decreaseBy(5) expect(store.getState().count).toBe(-4) expect(callback).toBeCalledTimes(2) }) it('can be reset', () => { const create = sangte({ count: 0 }) const store = create() store.setState({ count: 1 }) expect(store.getState().count).toBe(1) store.reset() expect(store.getState().count).toBe(0) }) it('should keep immutability', () => { const create = sangte({ count: 0 }, (prev) => ({ increase() { prev.count += 1 }, })) const store = create() const prev = store.getState() store.actions?.increase() const next = store.getState() expect(prev).not.toBe(next) }) it('should have config', () => { const create = sangte( { count: 0 }, { global: true, key: 'counter', } ) expect(create.config).toEqual({ global: true, key: 'counter', }) }) }) describe('resangte', () => { it('can be created', () => { const state = sangte([1, 2, 3, 4, 5]) const manager = new SangteManager() manager.get(state) const selectedState = sangte((get) => get(state).filter((number) => number > 2)) const store = manager.get(selectedState) expect(store.getState()).toEqual([3, 4, 5]) }) it('properly updates when dependencies change', () => { const state = sangte([1, 2, 3, 4, 5]) const manager = new SangteManager() manager.get(state) const selectedState = sangte((get) => get(state).filter((number) => number > 2)) const store = manager.get(selectedState) const callback = jest.fn() store.subscribe(callback) manager.get(state).setState([1, 2, 3, 4, 5, 6, 7]) expect(callback).toBeCalledTimes(1) expect(store.getState()).toEqual([3, 4, 5, 6, 7]) }) it('properly handles unmount & remount', () => { const state = sangte([1, 2, 3, 4, 5]) const manager = new SangteManager() manager.get(state) const selectedState = sangte((get) => get(state).filter((number) => number > 2)) const store = manager.get(selectedState) const callback = jest.fn() let unsubscribe = store.subscribe(callback) unsubscribe() manager.get(state).setState([1, 2, 3, 4, 5, 6, 7]) expect(callback).not.toBeCalled() // still gets updated because getState calls selector when unmounted expect(store.getState()).toEqual([3, 4, 5, 6, 7]) }) it('warns when calling setState or reset', () => { const state = sangte([1, 2, 3, 4, 5]) const manager = new SangteManager() manager.get(state) const selectedState = sangte((get) => get(state).filter((number) => number > 2)) const store = manager.get(selectedState) const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation() store.reset() store.setState([1, 2, 3]) expect(consoleWarnMock).toBeCalledTimes(2) consoleWarnMock.mockRestore() }) it('throws error when manager is not used', () => { const state = sangte([1, 2, 3, 4, 5]) const selectedState = sangte((get) => get(state).filter((number) => number > 2)) expect(() => { const store = selectedState() }).toThrowError() }) }) ================================================ FILE: src/lib/__tests__/shallowEqual.test.ts ================================================ import { shallowEqual } from '../shallowEqual' describe('shallowEqual', () => { it('returns true if comparing same object', () => { const obj = { a: 1, b: 2 } expect(shallowEqual(obj, obj)).toBe(true) }) it('returns true if they are shallowly equal', () => { const obj1 = { a: 1, b: 2 } const obj2 = { a: 1, b: 2 } expect(shallowEqual(obj1, obj2)).toBe(true) }) it('returns false if they are shallowly different', () => { const obj1 = { a: 1, b: 2 } const obj2 = { a: 1, b: 3 } expect(shallowEqual(obj1, obj2)).toBe(false) }) it('returns true when arrays are shallowly equal', () => { const arr1 = [1, 2] const arr2 = [1, 2] expect(shallowEqual(arr1, arr2)).toBe(true) }) it('returns false when arrays are shallowly different', () => { const arr1 = [1, 2] const arr2 = [1, 5] expect(shallowEqual(arr1, arr2)).toBe(false) }) }) ================================================ FILE: src/lib/index.ts ================================================ export * from './sangte' export * from './SangteInitializer' export * from './SangteManager' ================================================ FILE: src/lib/sangte.ts ================================================ import produce, { Draft, isDraftable } from 'immer' import { SangteManager } from './SangteManager' type Fn = () => void type UpdateFn = (state: T) => T export type ActionRecord = Record T | Draft | void> type Action = A extends ActionRecord ? A : never export type Actions = (prevState: Draft | T) => Action export interface SangteConfig { key?: string global?: boolean isResangte?: boolean } export type Getter = { (sangte: Sangte): T } export type SangteInstance = { initialState: T getState: () => T setState: (update: UpdateFn | T) => void subscribe: (callback: Fn) => Fn actions: Action | null reset: () => void } export type Sangte = { (manager?: SangteManager): SangteInstance config: SangteConfig } export type UnwrapSangteValue = T extends Sangte ? U : never export type UnwrapSangteAction = T extends Sangte ? U : never type Selector = (get: Getter) => T function isSelector(fn: any): fn is Selector { return typeof fn === 'function' } function isUpdateFn(value: any): value is UpdateFn { return typeof value === 'function' } function createSangte(initialState: T, createActions?: Actions): SangteInstance { let state = initialState const callbacks = new Set() function getState() { return state } function setState(update: UpdateFn | T) { if (isUpdateFn(update)) { state = update(state) } else { state = update } callbacks.forEach((cb) => cb()) } function subscribe(callback: Fn): Fn { callbacks.add(callback) return () => callbacks.delete(callback) } function reset() { setState(initialState) } const actions = (() => { if (!createActions) return null type ActionKey = keyof Action const record = createActions(initialState) const keys = Object.keys(record) as ActionKey[] keys.forEach((key) => { record[key] = ((...params: any[]) => { setState((prevState) => { if (!isDraftable(prevState)) { const action = createActions(prevState)[key] const next = action(...params) return next as any } const produced = produce(prevState, (draft) => { const action = createActions(draft)[key] const result = action(...params) if (result !== undefined) { return result as Draft } }) return produced }) }) as Action[ActionKey] }) return record })() return { initialState, getState, setState, subscribe, actions, reset, } } export function sangte(selector: (get: Getter) => T): Sangte export function sangte(initialState: T): Sangte export function sangte(initialState: T, config?: SangteConfig): Sangte export function sangte( initialState: T, actions: Actions, config?: SangteConfig ): Sangte export function sangte( selectorOrInitialState: T | ((get: Getter) => T), actionsOrConfig?: Actions | SangteConfig, config?: SangteConfig ) { if (isSelector(selectorOrInitialState)) { return resangte(selectorOrInitialState) } const hasActions = typeof actionsOrConfig === 'function' const sangte = function () { if (hasActions) { return createSangte(selectorOrInitialState, actionsOrConfig) } return createSangte(selectorOrInitialState) } if (hasActions) { sangte.config = config || {} } else { sangte.config = actionsOrConfig || {} } return sangte } function createResangte( selector: (getter: Getter) => T, sangteManager: SangteManager ): SangteInstance { let unmounted = false const sangteDeps = new Set>() const subscriptions = new Set<() => void>() const getter: Getter = (sangte) => { if (!sangteDeps.has(sangte)) { sangteDeps.add(sangte) } return sangteManager.get(sangte).getState() } let state = selector(getter) const callbacks = new Set() const getState = () => { if (unmounted) { unmounted = false state = selector(getter) } return state } const update = () => { state = selector(getter) callbacks.forEach((cb) => cb()) } const subscribe = (callback: Fn) => { if (callbacks.size === 0) { sangteDeps.forEach((sangte) => { const unsubscribe = sangteManager.get(sangte).subscribe(update) subscriptions.add(unsubscribe) }) } callbacks.add(callback) return () => { callbacks.delete(callback) if (callbacks.size === 0) { subscriptions.forEach((unsubscribe) => unsubscribe()) subscriptions.clear() unmounted = true } } } const setState = () => { console.warn('setState is not supported in resangte') } const reset = () => { console.warn('reset is not supported in resangte') } return { initialState: state, actions: null, getState, subscribe, setState, reset, } } export function resangte(selector: (getter: Getter) => T): Sangte { const resangte = function (sangteManager?: SangteManager) { if (!sangteManager) { throw new Error('Cannot create resangte without a manager') } return createResangte(selector, sangteManager) } resangte.config = { isResangte: true, } return resangte } ================================================ FILE: src/lib/shallowEqual.ts ================================================ export function shallowEqual>(a: T, b: T) { if (a === b) return true if (!(a instanceof Object) || !(b instanceof Object)) return false const keys = Object.keys(a) as (keyof T)[] const length = keys.length for (let i = 0; i < length; i++) if (!(keys[i] in b)) return false for (let i = 0; i < length; i++) if (a[keys[i]] !== b[keys[i]]) return false return length === Object.keys(b).length } ================================================ FILE: src/setupTest.ts ================================================ import '@testing-library/jest-dom/extend-expect' ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "include": ["src"] }