├── .changeset ├── README.md ├── changelog-config.js └── config.json ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── yarn-nm-install │ │ └── action.yml └── workflows │ ├── ci-packages.yml │ ├── clean-up-pr-caches.yml │ ├── comment.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .yarn └── releases │ └── yarn-4.6.0.cjs ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config ├── .ncurc.yml ├── eslint │ ├── bases │ │ ├── javascript.cjs │ │ ├── playwright.cjs │ │ ├── prettier.cjs │ │ ├── react-compiler.cjs │ │ ├── react.cjs │ │ ├── regexp.cjs │ │ ├── rtl.cjs │ │ ├── typescript.cjs │ │ ├── unicorn.cjs │ │ └── vitest.cjs │ ├── helpers │ │ ├── getDefaultIgnorePatterns.cjs │ │ └── getPrettierConfig.cjs │ └── prettier.base.config.cjs ├── tsconfig.build.json ├── tsconfig.test.json ├── tsup.config.ts └── turbowatch.config.ts ├── package.json ├── packages └── jotai-x │ ├── .npmignore │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── atomWithFn.ts │ ├── createAtomProvider.tsx │ ├── createAtomStore.spec.tsx │ ├── createAtomStore.ts │ ├── elementAtom.spec.tsx │ ├── index.ts │ └── useHydrateStore.ts │ └── tsconfig.json ├── prettier.config.cjs ├── scripts ├── styleMock.cjs ├── typedoc.json └── vitest.setup.ts ├── tsconfig.json ├── turbo.json ├── vitest.config.ts └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/changelog-config.js: -------------------------------------------------------------------------------- 1 | const { config } = require("dotenv"); 2 | const { getInfo, getInfoFromPullRequest } = require("@changesets/get-github-info"); 3 | 4 | config(); 5 | 6 | module.exports = { 7 | getDependencyReleaseLine: async ( 8 | ) => { 9 | return "" 10 | }, 11 | getReleaseLine: async (changeset, type, options) => { 12 | if (!options || !options.repo) { 13 | throw new Error( 14 | 'Please provide a repo to this changelog generator like this:\n"changelog": ["@changesets/changelog-github", { "repo": "org/repo" }]' 15 | ); 16 | } 17 | 18 | let prFromSummary; 19 | let commitFromSummary; 20 | let usersFromSummary = []; 21 | 22 | const replacedChangelog = changeset.summary 23 | .replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => { 24 | let num = Number(pr); 25 | if (!isNaN(num)) prFromSummary = num; 26 | return ""; 27 | }) 28 | .replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => { 29 | commitFromSummary = commit; 30 | return ""; 31 | }) 32 | .replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => { 33 | usersFromSummary.push(user); 34 | return ""; 35 | }) 36 | .trim(); 37 | 38 | const [firstLine, ...futureLines] = replacedChangelog 39 | .split("\n") 40 | .map(l => l.trimRight()); 41 | 42 | const links = await (async () => { 43 | if (prFromSummary !== undefined) { 44 | let { links } = await getInfoFromPullRequest({ 45 | repo: options.repo, 46 | pull: prFromSummary 47 | }); 48 | if (commitFromSummary) { 49 | links = { 50 | ...links, 51 | commit: `[\`${commitFromSummary}\`](https://github.com/${options.repo}/commit/${commitFromSummary})` 52 | }; 53 | } 54 | return links; 55 | } 56 | const commitToFetchFrom = commitFromSummary || changeset.commit; 57 | if (commitToFetchFrom) { 58 | let { links } = await getInfo({ 59 | repo: options.repo, 60 | commit: commitToFetchFrom 61 | }); 62 | return links; 63 | } 64 | return { 65 | commit: null, 66 | pull: null, 67 | user: null 68 | }; 69 | })(); 70 | 71 | const users = usersFromSummary.length 72 | ? usersFromSummary 73 | .map( 74 | userFromSummary => 75 | `[@${userFromSummary}](https://github.com/${userFromSummary})` 76 | ) 77 | .join(", ") 78 | : links.user; 79 | 80 | const pull = links.pull === null ? "" : ` ${links.pull}` 81 | const commit = !!pull || links.commit === null ? "" : ` ${links.commit}` 82 | 83 | const prefix = [ 84 | pull, 85 | commit, 86 | users === null ? "" : ` by ${users}` 87 | ].join(""); 88 | 89 | let lines = `${firstLine}\n${futureLines 90 | .map(l => ` ${l}`) 91 | .join("\n")}`; 92 | 93 | if (firstLine[0] === '-') { 94 | lines = `\n ${firstLine}\n${futureLines 95 | .map(l => ` ${l}`) 96 | .join("\n")}`; 97 | } 98 | 99 | return `\n\n-${prefix ? `${prefix} –` : ""} ${lines}`; 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", 3 | "changelog": ["./changelog-config", { "repo": "udecode/jotai-x" }], 4 | "commit": false, 5 | "linked": [["jotai-x"]], 6 | "access": "public", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const { 2 | getDefaultIgnorePatterns, 3 | } = require('./config/eslint/helpers/getDefaultIgnorePatterns.cjs'); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | root: true, 8 | extends: [ 9 | 'turbo', 10 | 11 | './config/eslint/bases/javascript.cjs', 12 | './config/eslint/bases/typescript.cjs', 13 | './config/eslint/bases/regexp.cjs', 14 | './config/eslint/bases/vitest.cjs', 15 | './config/eslint/bases/react.cjs', 16 | './config/eslint/bases/react-compiler.cjs', 17 | './config/eslint/bases/rtl.cjs', 18 | 19 | './config/eslint/bases/unicorn.cjs', 20 | 21 | './config/eslint/bases/prettier.cjs', 22 | ], 23 | ignorePatterns: [ 24 | ...getDefaultIgnorePatterns(), 25 | '.next', 26 | '.out', 27 | '**/__registry__', 28 | ], 29 | env: { 30 | browser: true, 31 | es6: true, 32 | node: true, 33 | webextensions: false, 34 | }, 35 | settings: { 36 | 'import/parsers': { 37 | '@typescript-eslint/parser': ['.ts', '.tsx'], 38 | }, 39 | 'import/resolver': { 40 | node: { 41 | moduleDirectory: ['node_modules'], 42 | typescript: { 43 | alwaysTryTypes: true, 44 | }, 45 | }, 46 | typescript: {}, 47 | }, 48 | react: { version: 'detect' }, 49 | }, 50 | rules: {}, 51 | overrides: [ 52 | { 53 | files: ['**/*.spec.*'], 54 | extends: ['./config/eslint/bases/prettier.cjs'], 55 | rules: { 56 | 'react/jsx-key': 'off', 57 | 'import/no-relative-packages': 'off', 58 | 'import/no-unresolved': 'off', 59 | }, 60 | }, 61 | { 62 | files: ['**/*.test.*', '**/*.spec.*', '**/*.fixture.*'], 63 | env: { 64 | jest: true, 65 | }, 66 | rules: { 67 | '@typescript-eslint/no-unused-vars': 'off', 68 | // 'react-hooks/rules-of-hooks': 'off', 69 | 'no-restricted-imports': [ 70 | 'error', 71 | { 72 | paths: [], 73 | }, 74 | ], 75 | }, 76 | }, 77 | { 78 | files: '**/*.mdx', 79 | rules: { 80 | 'prettier/prettier': 'off', 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: '🚨 Bug' 3 | about: A bug that occurs in jotai-x logic 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Description** 10 | 11 | 12 | 13 | **Steps** 14 | 15 | 16 | 21 | 22 | 23 | 24 | **Expectation** 25 | 26 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: '💬 Support: Discussions' 4 | url: https://github.com/udecode/jotai-x/discussions 5 | about: Share ideas, ask and answer questions in our Discussions. 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Description** 2 | 3 | See changesets. 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | -------------------------------------------------------------------------------- /.github/actions/yarn-nm-install/action.yml: -------------------------------------------------------------------------------- 1 | ######################################################################################## 2 | # "yarn install" composite action for yarn 3/4+ and "nodeLinker: node-modules" # 3 | #--------------------------------------------------------------------------------------# 4 | # Requirement: @setup/node should be run before # 5 | # # 6 | # Usage in workflows steps: # 7 | # # 8 | # - name: 📥 Monorepo install # 9 | # uses: ./.github/actions/yarn-nm-install # 10 | # with: # 11 | # enable-corepack: false # (default = 'false') # 12 | # cache-npm-cache: false # (default = 'true') # 13 | # cwd: ${{ github.workspace }}/apps/my-app # (default = '.') # 14 | # cache-prefix: add cache key prefix # (default = 'default') # 15 | # cache-node-modules: false # (default = 'false') # 16 | # cache-install-state: false # (default = 'false') # 17 | # # 18 | # Reference: # 19 | # - latest: https://gist.github.com/belgattitude/042f9caf10d029badbde6cf9d43e400a # 20 | # # 21 | # Versions: # 22 | # - 1.1.0 - 22-07-2023 - Option to enable npm global cache folder. # 23 | # - 1.0.4 - 15-07-2023 - Fix corepack was always enabled. # 24 | # - 1.0.3 - 05-07-2023 - YARN_ENABLE_MIRROR to false (speed up cold start) # 25 | # - 1.0.2 - 02-06-2023 - install-state default to false # 26 | # - 1.0.1 - 29-05-2023 - cache-prefix doc # 27 | # - 1.0.0 - 27-05-2023 - new input: cache-prefix # 28 | ######################################################################################## 29 | 30 | name: 'Monorepo install (yarn)' 31 | description: 'Run yarn install with node_modules linker and cache enabled' 32 | inputs: 33 | cwd: 34 | description: "Changes node's process.cwd() if the project is not located on the root. Default to process.cwd()" 35 | required: false 36 | default: '.' 37 | cache-prefix: 38 | description: 'Add a specific cache-prefix' 39 | required: false 40 | default: 'default' 41 | cache-npm-cache: 42 | description: 'Cache npm global cache folder often used by node-gyp, prebuild binaries (invalidated on lock/os/node-version)' 43 | required: false 44 | default: 'true' 45 | cache-node-modules: 46 | description: 'Cache node_modules, might speed up link step (invalidated lock/os/node-version/branch)' 47 | required: false 48 | default: 'false' 49 | cache-install-state: 50 | description: 'Cache yarn install state, might speed up resolution step when node-modules cache is activated (invalidated lock/os/node-version/branch)' 51 | required: false 52 | default: 'false' 53 | enable-corepack: 54 | description: 'Enable corepack' 55 | required: false 56 | default: 'true' 57 | 58 | runs: 59 | using: 'composite' 60 | 61 | steps: 62 | - name: ⚙️ Enable Corepack 63 | if: inputs.enable-corepack == 'true' 64 | shell: bash 65 | working-directory: ${{ inputs.cwd }} 66 | run: corepack enable 67 | 68 | - name: ⚙️ Expose yarn config as "$GITHUB_OUTPUT" 69 | id: yarn-config 70 | shell: bash 71 | working-directory: ${{ inputs.cwd }} 72 | env: 73 | YARN_ENABLE_GLOBAL_CACHE: 'false' 74 | run: | 75 | echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT 76 | echo "CURRENT_NODE_VERSION="node-$(node --version)"" >> $GITHUB_OUTPUT 77 | echo "CURRENT_BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's,/,-,g')" >> $GITHUB_OUTPUT 78 | echo "NPM_GLOBAL_CACHE_FOLDER=$(npm config get cache)" >> $GITHUB_OUTPUT 79 | 80 | - name: ♻️ Restore yarn cache 81 | uses: actions/cache@v3 82 | id: yarn-download-cache 83 | with: 84 | path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }} 85 | key: yarn-download-cache-${{ inputs.cache-prefix }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} 86 | restore-keys: | 87 | yarn-download-cache-${{ inputs.cache-prefix }}- 88 | 89 | - name: ♻️ Restore node_modules 90 | if: inputs.cache-node-modules == 'true' 91 | id: yarn-nm-cache 92 | uses: actions/cache@v3 93 | with: 94 | path: ${{ inputs.cwd }}/**/node_modules 95 | key: yarn-nm-cache-${{ inputs.cache-prefix }}-${{ runner.os }}-${{ steps.yarn-config.outputs.CURRENT_NODE_VERSION }}-${{ steps.yarn-config.outputs.CURRENT_BRANCH }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} 96 | 97 | - name: ♻️ Restore global npm cache folder 98 | if: inputs.cache-npm-cache == 'true' 99 | id: npm-global-cache 100 | uses: actions/cache@v3 101 | with: 102 | path: ${{ steps.yarn-config.outputs.NPM_GLOBAL_CACHE_FOLDER }} 103 | key: npm-global-cache-${{ inputs.cache-prefix }}-${{ runner.os }}-${{ steps.yarn-config.outputs.CURRENT_NODE_VERSION }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} 104 | 105 | - name: ♻️ Restore yarn install state 106 | if: inputs.cache-install-state == 'true' && inputs.cache-node-modules == 'true' 107 | id: yarn-install-state-cache 108 | uses: actions/cache@v3 109 | with: 110 | path: ${{ inputs.cwd }}/.yarn/ci-cache 111 | key: yarn-install-state-cache-${{ inputs.cache-prefix }}-${{ runner.os }}-${{ steps.yarn-config.outputs.CURRENT_NODE_VERSION }}-${{ steps.yarn-config.outputs.CURRENT_BRANCH }}-${{ hashFiles(format('{0}/yarn.lock', inputs.cwd), format('{0}/.yarnrc.yml', inputs.cwd)) }} 112 | 113 | - name: 📥 Install dependencies 114 | shell: bash 115 | working-directory: ${{ inputs.cwd }} 116 | run: yarn install --immutable --inline-builds 117 | env: 118 | # Overrides/align yarnrc.yml options (v3, v4) for a CI context 119 | YARN_ENABLE_GLOBAL_CACHE: 'false' # Use local cache folder to keep downloaded archives 120 | YARN_ENABLE_MIRROR: 'false' # Prevent populating global cache for caches misses (local cache only) 121 | YARN_NM_MODE: 'hardlinks-local' # Reduce node_modules size 122 | YARN_INSTALL_STATE_PATH: '.yarn/ci-cache/install-state.gz' # Might speed up resolution step when node_modules present 123 | # Other environment variables 124 | HUSKY: '0' # By default do not run HUSKY install -------------------------------------------------------------------------------- /.github/workflows/ci-packages.yml: -------------------------------------------------------------------------------- 1 | name: CI-packages 2 | 3 | on: 4 | push: 5 | branches: [main, next] 6 | paths: 7 | - 'packages/**' 8 | - 'package.json' 9 | - '*.lock' 10 | - '.yarnrc.yml' 11 | - 'tsconfig.json' 12 | - '.prettier*' 13 | - '.github/**' 14 | - 'config/**' 15 | - 'scripts/**' 16 | - 'jest.config.cjs' 17 | - 'prettier.config.cjs' 18 | - 'turbo.json' 19 | 20 | pull_request: 21 | types: [opened, synchronize, reopened] 22 | paths: 23 | - 'packages/**' 24 | - 'package.json' 25 | - '*.lock' 26 | - '.yarnrc.yml' 27 | - 'tsconfig.base.json' 28 | - '.prettier*' 29 | - '.github/**' 30 | - 'config/**' 31 | - 'scripts/**' 32 | - 'jest.config.cjs' 33 | - 'prettier.config.cjs' 34 | - 'turbo.json' 35 | 36 | jobs: 37 | test: 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | node-version: [20.x] 42 | # env: 43 | # TURBO_API: 'http://127.0.0.1:9080' 44 | # TURBO_TEAM: 'nextjs-monorepo-example' 45 | # TURBO_TOKEN: 'local_server_turbo_relaxed_token' 46 | steps: 47 | - uses: actions/checkout@v4 48 | 49 | - name: Use Node.js ${{ matrix.node-version }} 50 | uses: actions/setup-node@v4 51 | with: 52 | node-version: ${{ matrix.node-version }} 53 | 54 | # - name: ⏩ TurboRepo local server 55 | # uses: felixmosh/turborepo-gh-artifacts@v2 56 | # with: 57 | # repo-token: ${{ secrets.GITHUB_TOKEN }} 58 | # server-token: ${{ env.TURBO_TOKEN }} 59 | 60 | - name: 📥 Monorepo install 61 | uses: ./.github/actions/yarn-nm-install 62 | 63 | - name: ♻️ Restore packages cache 64 | uses: actions/cache@v3 65 | with: 66 | path: | 67 | ${{ github.workspace }}/.cache 68 | ${{ github.workspace }}/**/tsconfig.tsbuildinfo 69 | key: packages-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }} 70 | 71 | - name: 🕵️ Typecheck 72 | run: yarn typecheck 73 | 74 | - name: 🔬 Linter 75 | run: yarn lint 76 | 77 | - name: 🧪 Unit tests 78 | run: yarn test 79 | 80 | - name: 🏗 Run build 81 | run: yarn build 82 | -------------------------------------------------------------------------------- /.github/workflows/clean-up-pr-caches.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries 2 | name: Cleanup caches for closed branches 3 | 4 | on: 5 | pull_request: 6 | types: [closed] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | cleanup: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v4 15 | 16 | - name: 🧹 Cleanup 17 | run: | 18 | gh extension install actions/gh-actions-cache 19 | 20 | REPO=${{ github.repository }} 21 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 22 | 23 | echo "Fetching list of cache key" 24 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 25 | 26 | ## Setting this to not fail the workflow while deleting cache keys. 27 | set +e 28 | echo "Deleting caches..." 29 | for cacheKey in $cacheKeysForPR 30 | do 31 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 32 | done 33 | echo "Done" 34 | env: 35 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/comment.yml: -------------------------------------------------------------------------------- 1 | # Define actions that run in response to comments on issues and PRs 2 | # Based on https://github.com/ianstormtaylor/slate/blob/main/.github/workflows/comment.yml 3 | # 4 | # Allowed GitHub users: zbeyens, 12joan 5 | # 6 | # Supported comments: 7 | # - /release:next (PR only) - Publish the branch to NPM with tag 'next' 8 | 9 | name: Comment 10 | 11 | on: 12 | issue_comment: 13 | types: 14 | - created 15 | 16 | jobs: 17 | release_next: 18 | permissions: 19 | contents: read # to fetch code (actions/checkout) 20 | pull-requests: write # to create or update comment (peter-evans/create-or-update-comment) 21 | 22 | name: release:next 23 | runs-on: ubuntu-latest 24 | if: | 25 | github.event.issue.pull_request && 26 | contains(fromJSON('["zbeyens", "12joan"]'), github.event.sender.login) && 27 | startsWith(github.event.comment.body, '/release:next') 28 | steps: 29 | - name: Checkout repo 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 0 33 | 34 | - name: Checkout pull request 35 | run: gh pr checkout ${{ github.event.issue.number }} 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Setup node 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: 20.x 43 | cache: yarn 44 | registry-url: https://registry.npmjs.org 45 | key: node20 46 | 47 | - name: ♻️ Use Node.js 20.x 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: 20.x 51 | 52 | - name: 📥 Monorepo install 53 | uses: ./.github/actions/yarn-nm-install 54 | 55 | - name: Publish to NPM 56 | run: | 57 | yarn changeset version --snapshot 58 | yarn release:next 59 | env: 60 | # See https://github.com/changesets/action/issues/147 61 | HOME: ${{ github.workspace }} 62 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 65 | 66 | - name: Create comment 67 | uses: peter-evans/create-or-update-comment@v1 68 | with: 69 | issue-number: ${{ github.event.issue.number }} 70 | body: | 71 | A new release has been made for this pull request. 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: ReleaseOrVersionPR 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | release: 9 | # Basic security: the release job can only be executed from this repo and from the main branch (not a remote thing) 10 | if: ${{ github.repository == 'udecode/jotai-x' && contains('refs/heads/main',github.ref)}} 11 | name: Release and changelog 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v4 16 | with: 17 | # To run comparison we need more than the latest commit. 18 | # @link https://github.com/actions/checkout#fetch-all-history-for-all-tags-and-branches 19 | fetch-depth: 0 20 | 21 | - name: ♻️ Use Node.js 20.x 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20.x 25 | 26 | - name: 📥 Monorepo install 27 | uses: ./.github/actions/yarn-nm-install 28 | 29 | # @link https://github.com/changesets/action 30 | - name: 🦋 Create Release Pull Request or Publish to npm 31 | id: changesets 32 | uses: changesets/action@v1 33 | with: 34 | # publish: yarn g:release 35 | cwd: ${{ github.workspace }} 36 | title: '[Release] Version packages' 37 | publish: yarn release 38 | # Optional, might be used in conjunction with GITHUB_TOKEN to 39 | # allow running the workflows on a Version package action. 40 | # Be aware of security implications. 41 | # setupGitUser: true 42 | env: 43 | # See https://github.com/changesets/action/issues/147 44 | HOME: ${{ github.workspace }} 45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | packages/cli/components 4 | **/tsconfig.tsbuildinfo 5 | 6 | .yarn/* 7 | !.yarn/patches 8 | !.yarn/releases 9 | !.yarn/plugins 10 | !.yarn/sdks 11 | !.yarn/versions 12 | 13 | typedoc 14 | 15 | **/.netrc 16 | 17 | # Generated files 18 | .history 19 | node_modules/ 20 | report.* 21 | 22 | # Dependency directories 23 | jspm_packages/ 24 | /packages/*/node_modules 25 | **/dist 26 | 27 | # compiled output 28 | /dist 29 | /tmp 30 | /out-tsc 31 | **/build 32 | 33 | # Runtime data 34 | pids 35 | *.pid 36 | *.seed 37 | *.pid.lock 38 | 39 | # Directory for instrumented libs generated by jscoverage/JSCover 40 | lib-cov 41 | 42 | # Coverage directory used by tools like istanbul 43 | coverage 44 | 45 | # nyc test coverage 46 | .nyc_output 47 | 48 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 49 | .grunt 50 | 51 | # Bower dependency directory (https://bower.io/) 52 | bower_components 53 | 54 | # node-waf configuration 55 | .lock-wscript 56 | 57 | # IDEs and editors 58 | .idea 59 | .project 60 | .classpath 61 | .c9/ 62 | *.launch 63 | .settings/ 64 | *.sublime-workspace 65 | .vscode/* 66 | 67 | # Logs 68 | logs 69 | *.log 70 | npm-debug.log* 71 | yarn-debug.log* 72 | yarn-error.log* 73 | *.cache 74 | 75 | 76 | 77 | # Optional npm cache directory 78 | .npm 79 | 80 | # Optional eslint cache 81 | .eslintcache 82 | 83 | # Optional REPL history 84 | .node_repl_history 85 | 86 | # Output of 'npm pack' 87 | *.tgz 88 | 89 | # Yarn Integrity file 90 | .yarn-integrity 91 | 92 | # dotenv environment variables file 93 | .env 94 | .env.local 95 | .env.development.local 96 | .env.test.local 97 | .env.production.local 98 | 99 | # next.js build output 100 | .next 101 | 102 | # System Files 103 | .DS_Store 104 | Thumbs.db 105 | 106 | #sonar 107 | .scannerwork/ 108 | 109 | # misc 110 | .sass-cache 111 | connect.lock 112 | 113 | #storybook 114 | storybook-static/ 115 | 116 | # Local Netlify folder 117 | .netlify 118 | /test-results/ 119 | /playwright-report/ 120 | /playwright/.cache/ 121 | .turbo 122 | .vercel 123 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # used in tandem with package.json engines section to only use yarn 2 | engine-strict=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: 0 2 | 3 | defaultSemverRangePrefix: "" 4 | 5 | enableGlobalCache: false 6 | 7 | nmMode: hardlinks-local 8 | 9 | nodeLinker: node-modules 10 | 11 | npmRegistryServer: "https://registry.npmjs.org/" 12 | 13 | supportedArchitectures: 14 | cpu: 15 | - current 16 | libc: 17 | - current 18 | os: 19 | - current 20 | 21 | yarnPath: .yarn/releases/yarn-4.6.0.cjs 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | ### Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ### Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ### Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ### Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ### Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ### Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in improving `jotai-x`! We are a 4 | community-driven project and welcome contributions of all kinds: from 5 | discussion to documentation to bugfixes to feature improvements. 6 | 7 | Please review this document to help to streamline the process and save 8 | everyone's precious time. 9 | 10 | ## Issues 11 | 12 | No software is bug-free. So, if you got an issue, follow these steps: 13 | 14 | - Search the 15 | [issue list](https://github.com/udecode/jotai-x/issues?utf8=%E2%9C%93&q=) 16 | for current and old issues. 17 | - If you find an existing issue, please UPVOTE the issue by adding a 18 | "thumbs-up reaction". We use this to help prioritize issues! 19 | - If none of that is helping, please create an issue. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ## Development Guide 28 | 29 | ### Initial Setup 30 | 31 | This repo uses yarn workspaces, so you should install `yarn` as the 32 | package manager. See 33 | [installation guide](https://yarnpkg.com/en/docs/install). 34 | 35 | 1. `cd ~` (optional) 36 | 2. `git clone https://github.com/udecode/jotai-x.git` _bonus_: use your own fork for this step 37 | 3. `cd jotai-x` 38 | 4. `corepack enable` 39 | 5. `yarn install` 40 | 6. `yarn build` 41 | 42 | ### Editing 43 | 44 | #### Run Linter 45 | 46 | We use eslint as a linter for all code (including typescript code). 47 | 48 | All you have to run is: 49 | 50 | ```sh 51 | yarn lint:fix 52 | ``` 53 | 54 | #### Run unit tests 55 | 56 | This command will list all the suites and options for running tests. 57 | 58 | ```sh 59 | yarn test 60 | ``` 61 | 62 | The options for running tests can be selected from the cli or be passed 63 | to `yarn test` with specific parameters. Available modes include 64 | `--watch`, `--coverage`, and `--runInBand`, which will respectively run 65 | tests in watch mode, output code coverage, and run selected test suites 66 | serially in the current process. 67 | 68 | You need to `yarn build` before you run tests 69 | 70 | #### Updating Tests 71 | 72 | Before any contributions are submitted in a PR, make sure to add or 73 | update meaningful tests. A PR that has failing tests will be regarded as 74 | a “Work in Progress” and will not be merged until all tests pass. When 75 | creating new unit test files, the tests should adhere to a particular 76 | folder structure and naming convention, as defined below. 77 | 78 | ```sh 79 | # Proper naming convention and structure for test files 80 | +-- filename-to-test.spec.ts 81 | ``` 82 | 83 | ## Release Guide 84 | 85 | This section is for anyone wanting a release. The current release 86 | sequence is as follows: 87 | 88 | - Commit your changes: 89 | - If you want to synchronize the exports, run `yarn cti` to 90 | automatically update the index files. 91 | - Lint, test, build should pass. 92 | - Open a PR against `main` and 93 | [add a changeset](https://github.com/atlassian/changesets/blob/main/docs/adding-a-changeset.md). 94 | - To create a [snapshot release](https://github.com/atlassian/changesets/blob/main/docs/snapshot-releases.md), maintainers can comment a GitHub 95 | issue starting with `/release:next`. 96 | - Merge the PR, triggering the bot to create a PR release. 97 | - Review the final changesets. 98 | - Merge the PR release, triggering the bot to release the updated 99 | packages on npm. 100 | 101 | ## Pull Requests (PRs) 102 | 103 | We welcome all contributions. There are many ways you can help us. This 104 | is few of those ways: 105 | 106 | Before you submit a new PR, please run `yarn prerelease`. Do not submit 107 | a PR if tests are failing. 108 | 109 | ### Reviewing PRs 110 | 111 | **As a PR submitter**, you should reference the issue if there is one, 112 | include a short description of what you contributed and, if it is a code 113 | change, instructions for how to manually test out the change. This is 114 | informally enforced by our 115 | [PR template](https://github.com/udecode/jotai-x/blob/main/.github/PULL_REQUEST_TEMPLATE.md). 116 | If your PR is reviewed as only needing trivial changes (e.g. small typos 117 | etc), and you have commit access then you can merge the PR after making 118 | those changes. 119 | 120 | **As a PR reviewer**, you should read through the changes and comment on 121 | any potential problems. If you see something cool, a kind word never 122 | hurts either! Additionally, you should follow the testing instructions 123 | and manually test the changes. If the instructions are missing, unclear, 124 | or overly complex, feel free to request better instructions from the 125 | submitter. Unless the PR is a draft, if you approve the review and there 126 | is no other required discussion or changes, you should also go ahead and 127 | merge the PR. 128 | 129 | ## Issue Triage 130 | 131 | If you are looking for a way to help the project, triaging issues is a 132 | great place to start. Here's how you can help: 133 | 134 | ### Responding to questions 135 | 136 | [Q&A](https://github.com/udecode/jotai-x/discussions/categories/q-a) is a 137 | great place to help. If you can answer a question, it will help the 138 | asker as well as anyone who has a similar question. Also in the future 139 | if anyone has that same question they can easily find it by searching. 140 | If an issue needs reproduction, you may be able to guide the reporter 141 | toward one, or even reproduce it yourself using 142 | codesandbox. 143 | 144 | ### Triaging issues 145 | 146 | Once you've helped out on a few issues, if you'd like triage access you 147 | can help label issues and respond to reporters. 148 | 149 | We use the following label scheme to categorize issues: 150 | 151 | - **type** - `bug`, `feature`, `dependencies`, `maintenance`. 152 | 153 | - **status** - `needs reproduction`, etc. 154 | 155 | All issues should have a `type` label. 156 | `dependencies` is for keeping package dependencies up to date. 157 | `maintenance` is a catch-all for any kind of cleanup or refactoring. 158 | 159 | They should also have one or more `area`/`status` labels. We use these 160 | labels to filter issues down so we can see all of the issues for a 161 | particular area, and keep the total number of open issues under control. 162 | 163 | For example, here is the list of 164 | [open, untyped issues](https://github.com/udecode/jotai-x/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20-label%3A%22bug%22%20-label%3A%22discussion%22%20-label%3A%22feature%22%20-label%3A%22maintenance%22%20-label%3A%22question%20%2F%20support%22%20-label%3A%22documentation%22%20-label%3A%22greenkeeper%22). 165 | For more info see 166 | [searching issues](https://help.github.com/articles/searching-issues/) 167 | in the Github docs. 168 | 169 | If an issue is a `bug`, and it doesn't have a clear reproduction that 170 | you have personally confirmed, label it `needs reproduction` and ask the 171 | author to try and create a reproduction, or have a go yourself. 172 | 173 | ### Closing issues 174 | 175 | - Duplicate issues should be closed with a link to the original. 176 | - Unreproducible issues should be closed if it's not possible to 177 | reproduce them (if the reporter drops offline, it is reasonable to 178 | wait 2 weeks before closing). 179 | - `bug`s should be closed when the issue is fixed and released. 180 | - `feature`s, `maintenance`s, should be closed when released or if the 181 | feature is deemed not appropriate. 182 | 183 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Ziad Beyens 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jotai X 2 | 3 | An extension for [Jotai](https://github.com/pmndrs/jotai) that auto-generates type-safe hooks and utilities for your state. Built with TypeScript and React in mind. 4 | 5 | ## Features 6 | 7 | - Auto-generated type-safe hooks for each state field 8 | - Simple patterns: `useValue(key)` and `useSet(key, value)` 9 | - Extend your store with computed values using `extend` 10 | - Built-in support for hydration, synchronization, and scoped providers 11 | 12 | ## Why 13 | 14 | Built on top of `jotai`, `jotai-x` offers a better developer experience with less boilerplate. Create and interact with stores faster using a more intuitive API. 15 | 16 | > Looking for global state management instead of React Context-based state? Check out [Zustand X](https://github.com/udecode/zustand-x) - same API, different state model. 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pnpm add jotai jotai-x 22 | ``` 23 | 24 | ## Quick Start 25 | 26 | Here's how to create a simple store: 27 | 28 | ```tsx 29 | import { createAtomStore } from 'jotai-x'; 30 | 31 | // Create a store with an initial state 32 | // Store name is used as prefix for all returned hooks (e.g., `useAppStore`, `useAppValue` for `name: 'app'`) 33 | const { useAppStore, useAppValue, useAppSet, useAppState, AppProvider } = 34 | createAtomStore( 35 | { 36 | name: 'JotaiX', 37 | stars: 0, 38 | }, 39 | { 40 | name: 'app', 41 | } 42 | ); 43 | 44 | // Use it in your components 45 | function RepoInfo() { 46 | const name = useAppValue('name'); 47 | const stars = useAppValue('stars'); 48 | 49 | return ( 50 |
51 |

{name}

52 |

{stars} stars

53 |
54 | ); 55 | } 56 | 57 | function AddStarButton() { 58 | const setStars = useAppSet('stars'); 59 | 60 | return ; 61 | } 62 | ``` 63 | 64 | ## Core Concepts 65 | 66 | ### Store Configuration 67 | 68 | The store is where everything begins. Configure it with type-safe options: 69 | 70 | ```ts 71 | import { createAtomStore } from 'jotai-x'; 72 | 73 | // Types are inferred, including options 74 | const { useUserValue, useUserSet, useUserState, UserProvider } = 75 | createAtomStore( 76 | { 77 | name: 'Alice', 78 | loggedIn: false, 79 | }, 80 | { 81 | name: 'user', 82 | delay: 100, // Optional delay for state updates 83 | effect: EffectComponent, // Optional effect component 84 | extend: (atoms) => ({ 85 | // Optional derived atoms 86 | intro: atom((get) => `My name is ${get(atoms.name)}`), 87 | }), 88 | infiniteRenderDetectionLimit: 100, // Optional render detection limit 89 | } 90 | ); 91 | ``` 92 | 93 | Available options: 94 | 95 | ```ts 96 | { 97 | name: string; 98 | delay?: number; 99 | effect?: React.ComponentType; 100 | extend?: (atoms: Atoms) => DerivedAtoms; 101 | infiniteRenderDetectionLimit?: number; 102 | } 103 | ``` 104 | 105 | ### Store API 106 | 107 | The `createAtomStore` function returns an object with the following: 108 | 109 | ```ts 110 | const { 111 | // Store name used as prefix 112 | name: string, 113 | 114 | // Store hook returning all utilities 115 | useAppStore: () => StoreApi, 116 | 117 | // Direct hooks for state management 118 | useAppValue: (key: string, options?) => Value, 119 | useAppSet: (key: string) => SetterFn, 120 | useAppState: (key: string) => [Value, SetterFn], 121 | 122 | // Provider component 123 | AppProvider: React.FC, 124 | 125 | // Record of all atoms in the store 126 | appStore: { 127 | atom: Record 128 | } 129 | } = createAtomStore({ ... }, { name: 'app' }); 130 | ``` 131 | 132 | ### Reading and Writing State 133 | 134 | There are three ways to interact with the store state: 135 | 136 | #### 1. Hooks (Recommended) 137 | 138 | The most straightforward way using hooks returned by `createAtomStore`: 139 | 140 | ```ts 141 | // Get value 142 | const name = useAppValue('name'); 143 | const stars = useAppValue('stars'); 144 | 145 | // Set value 146 | const setName = useAppSet('name'); 147 | const setStars = useAppSet('stars'); 148 | 149 | // Get both value and setter 150 | const [name, setName] = useAppState('name'); 151 | const [stars, setStars] = useAppState('stars'); 152 | 153 | // With selector and deps 154 | const upperName = useAppValue('name', { 155 | selector: (name) => name.toUpperCase(), 156 | }, []); 157 | ``` 158 | 159 | #### 2. Store Instance Methods 160 | 161 | Using the store instance from `useAppStore()`: 162 | 163 | ```ts 164 | const store = useAppStore(); 165 | 166 | // By key 167 | store.get('name'); // Get value 168 | store.set('name', 'value'); // Set value 169 | store.subscribe('name', (value) => console.log(value)); // Subscribe to changes 170 | 171 | // Direct access 172 | store.getName(); // Get value 173 | store.setName('value'); // Set value 174 | store.subscribeName((value) => console.log(value)); // Subscribe to changes 175 | ``` 176 | 177 | #### 3. Raw Atom Access 178 | 179 | For advanced use cases, you can work directly with atoms: 180 | 181 | ```ts 182 | const store = useAppStore(); 183 | 184 | // Access atoms 185 | store.getAtom(someAtom); // Get atom value 186 | store.setAtom(someAtom, 'value'); // Set atom value 187 | store.subscribeAtom(someAtom, (value) => {}); // Subscribe to atom 188 | 189 | // Access underlying Jotai store 190 | const jotaiStore = store.store; 191 | ``` 192 | 193 | ### Hook API Reference 194 | 195 | #### `useValue(key, options?)` 196 | 197 | Subscribe to a single value with optional selector and deps: 198 | 199 | ```ts 200 | // Basic usage 201 | const name = useAppValue('name'); 202 | 203 | // With selector 204 | const upperName = useAppValue('name', { 205 | selector: (name) => name.toUpperCase(), 206 | }, [] // if selector is not memoized, provide deps array 207 | ); 208 | 209 | // With equality function 210 | const name = useAppValue('name', { 211 | selector: (name) => name, 212 | equalityFn: (prev, next) => prev.length === next.length 213 | }, []); 214 | ``` 215 | 216 | #### `useSet(key)` 217 | 218 | Get a setter function for a value: 219 | 220 | ```ts 221 | const setName = useAppSet('name'); 222 | setName('new value'); 223 | setName((prev) => prev.toUpperCase()); 224 | ``` 225 | 226 | #### `useState(key)` 227 | 228 | Get both value and setter, like React's `useState`: 229 | 230 | ```tsx 231 | function UserForm() { 232 | const [name, setName] = useAppState('name'); 233 | const [email, setEmail] = useAppState('email'); 234 | 235 | return ( 236 |
237 | setName(e.target.value)} /> 238 | setEmail(e.target.value)} /> 239 |
240 | ); 241 | } 242 | ``` 243 | 244 | ### Provider-Based Store Hydration 245 | 246 | The provider component handles store initialization and state synchronization: 247 | 248 | ```tsx 249 | type ProviderProps = { 250 | // Initial values for atoms, hydrated once on mount 251 | initialValues?: Partial; 252 | 253 | // Dynamic values for controlled state 254 | ...Partial; 255 | 256 | // Optional custom store instance 257 | store?: JotaiStore; 258 | 259 | // Optional scope for nested providers 260 | scope?: string; 261 | 262 | // Optional key to reset the store 263 | resetKey?: any; 264 | 265 | children: React.ReactNode; 266 | }; 267 | 268 | function App() { 269 | return ( 270 | 286 | 287 | 288 | ); 289 | } 290 | ``` 291 | 292 | ### Scoped Providers 293 | 294 | Create multiple instances of the same store with different scopes: 295 | 296 | ```tsx 297 | function App() { 298 | return ( 299 | 300 | 301 | 302 | 303 | 304 | ); 305 | } 306 | 307 | function UserProfile() { 308 | // Get parent scope 309 | const parentName = useUserValue('name', { scope: 'parent' }); 310 | // Get closest scope 311 | const name = useUserValue('name'); 312 | } 313 | ``` 314 | 315 | ### Derived Atoms 316 | 317 | Two ways to create derived atoms: 318 | 319 | ```ts 320 | // 1. Using extend 321 | const { useUserValue } = createAtomStore( 322 | { 323 | name: 'Alice', 324 | }, 325 | { 326 | name: 'user', 327 | extend: (atoms) => ({ 328 | intro: atom((get) => `My name is ${get(atoms.name)}`), 329 | }), 330 | } 331 | ); 332 | 333 | // Access the derived value using the store name 334 | const intro = useUserValue('intro'); 335 | 336 | // 2. External atoms 337 | const { userStore, useUserStore } = createAtomStore( 338 | { 339 | name: 'Alice', 340 | }, 341 | { 342 | name: 'user', 343 | } 344 | ); 345 | 346 | // Create an external atom 347 | const introAtom = atom((get) => `My name is ${get(userStore.atom.name)}`); 348 | 349 | // Create a writable external atom 350 | const countAtom = atom( 351 | (get) => get(userStore.atom.name).length, 352 | (get, set, newCount: number) => { 353 | set(userStore.atom.name, 'A'.repeat(newCount)); 354 | } 355 | ); 356 | 357 | // Get the store instance 358 | const store = useUserStore(); 359 | 360 | // Access external atoms using store-based atom hooks 361 | const intro = useAtomValue(store, introAtom); // Read-only atom 362 | const [count, setCount] = useAtomState(store, countAtom); // Read-write atom 363 | const setCount2 = useSetAtom(store, countAtom); // Write-only 364 | 365 | // With selector and deps 366 | const upperIntro = useAtomValue( 367 | store, 368 | introAtom, 369 | (intro) => intro.toUpperCase(), 370 | [] // Optional deps array for selector 371 | ); 372 | 373 | // With selector and equality function 374 | const intro2 = useAtomValue( 375 | store, 376 | introAtom, 377 | (intro) => intro, 378 | (prev, next) => prev.length === next.length // Optional equality function 379 | ); 380 | ``` 381 | 382 | The store-based atom hooks provide more flexibility when working with external atoms: 383 | 384 | - `useAtomValue(store, atom, selector?, equalityFnOrDeps?, deps?)`: Subscribe to a read-only atom value 385 | - `selector`: Transform the atom value (must be memoized or use deps) 386 | - `equalityFnOrDeps`: Custom comparison function or deps array 387 | - `deps`: Dependencies array when using both selector and equalityFn 388 | - `useSetAtom(store, atom)`: Get a setter function for a writable atom 389 | - `useAtomState(store, atom)`: Get both value and setter for a writable atom, like React's `useState` 390 | 391 | ## Troubleshooting 392 | 393 | ### Infinite Render Detection 394 | 395 | When using value hooks with selectors, ensure they are memoized: 396 | 397 | ```tsx 398 | // ❌ Wrong - will cause infinite renders 399 | useUserValue('name', { selector: (name) => name.toUpperCase() }); 400 | 401 | // ✅ Correct - memoize with useCallback 402 | const selector = useCallback((name) => name.toUpperCase(), []); 403 | useUserValue('name', { selector }); 404 | 405 | // ✅ Correct - provide deps array 406 | useUserValue('name', { selector: (name) => name.toUpperCase() }, []); 407 | 408 | // ✅ Correct - no selector 409 | useUserValue('name'); 410 | ``` 411 | 412 | ## Migration from v1 to v2 413 | 414 | ```ts 415 | // Before 416 | const { useAppStore } = createAtomStore({ name: 'Alice' }, { name: 'app' }); 417 | const name = useAppStore().get.name(); 418 | const setName = useAppStore().set.name(); 419 | const [name, setName] = useAppStore().use.name(); 420 | 421 | // Now 422 | const { useAppStore, useAppValue, useAppSet, useAppState } = createAtomStore({ name: 'Alice' }, { name: 'app' }); 423 | const name = useAppValue('name'); 424 | const setName = useAppSet('name'); 425 | const [name, setName] = useAppState('name'); 426 | ``` 427 | 428 | ## License 429 | 430 | [MIT](./LICENSE) 431 | -------------------------------------------------------------------------------- /config/.ncurc.yml: -------------------------------------------------------------------------------- 1 | # npm-check-updates configuration used by yarn deps:check && yarn deps:update 2 | # convenience scripts. 3 | # @link https://github.com/raineorshine/npm-check-updates 4 | 5 | # Add here exclusions on packages if any 6 | reject: [] -------------------------------------------------------------------------------- /config/eslint/bases/javascript.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:promise/recommended'], 3 | plugins: [], 4 | rules: { 5 | 'babel/no-unused-expressions': 'off', 6 | camelcase: 'off', 7 | 'class-methods-use-this': 'off', 8 | 'consistent-return': 'off', 9 | 10 | 'default-case': 'off', 11 | 12 | 'default-param-last': 'off', 13 | 'func-names': 'off', 14 | 'global-require': 'off', 15 | 'linebreak-style': 'off', 16 | 'lines-between-class-members': [ 17 | 'error', 18 | 'always', 19 | { exceptAfterSingleLine: true }, 20 | ], 21 | 'max-classes-per-file': 'off', 22 | 'no-alert': 'off', 23 | 'no-await-in-loop': 'off', 24 | 'no-bitwise': 'off', 25 | 'no-console': [ 26 | 'warn', 27 | { 28 | allow: ['info', 'warn', 'error'], 29 | }, 30 | ], 31 | 32 | 'no-constant-condition': 'off', 33 | 'no-continue': 'off', // or "@typescript-eslint/no-unused-vars": "off", 34 | // No unused imports 35 | 'no-empty': 'off', 36 | // No unused variables 37 | 'no-multi-assign': 'off', 38 | 'no-nested-ternary': 'off', 39 | 'no-new': 'off', 40 | 'no-param-reassign': 'off', 41 | 'no-plusplus': 'off', 42 | 'no-prototype-builtins': 'off', 43 | 'no-restricted-syntax': [ 44 | 'error', 45 | { 46 | selector: 'ForInStatement', 47 | message: 48 | 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', 49 | }, 50 | { 51 | selector: 'LabeledStatement', 52 | message: 53 | 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', 54 | }, 55 | { 56 | selector: 'WithStatement', 57 | message: 58 | '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', 59 | }, 60 | ], 61 | 'no-return-assign': 'off', // short 62 | 'no-shadow': 'off', // exceptions 63 | 'no-undef': 'off', 64 | 'no-underscore-dangle': 'off', // short 65 | 'no-unexpected-multiline': 'off', // short 66 | 'no-unused-expressions': 'off', // for..of OK (break) 67 | 'no-unused-vars': 'off', // short 68 | 'no-use-before-define': 'off', 69 | 'no-useless-constructor': 'off', 70 | 71 | 'prefer-promise-reject-errors': 'off', 72 | 'promise/always-return': 'off', 73 | 74 | 'promise/catch-or-return': 'off', 75 | 'promise/no-callback-in-promise': 'off', 76 | 'unused-imports/no-unused-imports': 'warn', 77 | 'unused-imports/no-unused-vars': [ 78 | 'warn', 79 | { 80 | ignoreRestSiblings: true, 81 | vars: 'all', 82 | varsIgnorePattern: '^_', 83 | args: 'none', 84 | argsIgnorePattern: '^_', 85 | }, 86 | ], 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /config/eslint/bases/playwright.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Opinionated config base for projects using playwright. 3 | * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases 4 | */ 5 | 6 | const playwrightPatterns = { 7 | files: ['**/e2e/**/*.test.{js,ts}'], 8 | }; 9 | 10 | module.exports = { 11 | overrides: [ 12 | { 13 | // To ensure best performance enable only on e2e test files 14 | files: playwrightPatterns.files, 15 | // @see https://github.com/playwright-community/eslint-plugin-playwright 16 | extends: ['plugin:playwright/recommended'], 17 | rules: { 18 | '@typescript-eslint/no-empty-function': 'off', 19 | '@typescript-eslint/no-non-null-assertion': 'off', 20 | '@typescript-eslint/no-object-literal-type-assertion': 'off', 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /config/eslint/bases/prettier.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom config base for projects using prettier. 3 | * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases 4 | */ 5 | const { getPrettierConfig } = require('../helpers/getPrettierConfig.cjs'); 6 | 7 | const { ...prettierConfig } = getPrettierConfig(); 8 | 9 | /** @type {import("eslint").Linter.Config} */ 10 | module.exports = { 11 | extends: ['prettier'], 12 | plugins: ['prettier'], 13 | rules: { 14 | 'arrow-body-style': 'off', 15 | 'prefer-arrow-callback': 'off', 16 | 'prettier/prettier': ['warn', prettierConfig], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /config/eslint/bases/react-compiler.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['react-compiler'], 3 | rules: { 4 | 'react-compiler/react-compiler': 'error', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /config/eslint/bases/react.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Opinionated config base for projects using react. 3 | * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases 4 | */ 5 | 6 | const reactPatterns = { 7 | files: ['*.{jsx,tsx}'], 8 | }; 9 | 10 | module.exports = { 11 | env: { 12 | browser: true, 13 | es6: true, 14 | node: true, 15 | }, 16 | settings: { 17 | react: { 18 | version: 'detect', 19 | }, 20 | }, 21 | extends: [ 22 | // @see https://www.npmjs.com/package/eslint-plugin-react-hooks 23 | 'plugin:mdx/recommended', 24 | ], 25 | rules: { 26 | // 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 27 | // 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies 28 | }, 29 | overrides: [ 30 | { 31 | files: reactPatterns.files, 32 | extends: [ 33 | // @see https://github.com/yannickcr/eslint-plugin-react 34 | 'plugin:react/recommended', 35 | // @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y 36 | 'plugin:jsx-a11y/recommended', 37 | ], 38 | rules: { 39 | 'jsx-a11y/anchor-has-content': 'off', 40 | 'jsx-a11y/anchor-is-valid': 'off', 41 | 42 | // For the sake of example 43 | // https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md 44 | 'jsx-a11y/click-events-have-key-events': 'off', 45 | 'jsx-a11y/heading-has-content': 'off', 46 | 'jsx-a11y/label-has-associated-control': 'off', 47 | 'jsx-a11y/label-has-for': 'off', 48 | 'jsx-a11y/no-autofocus': 'off', 49 | 'jsx-a11y/no-static-element-interactions': 'off', 50 | 'mdx/no-unescaped-entities': 'off', 51 | 'mdx/no-unused-expressions': 'off', 52 | 53 | // 'react-hooks/exhaustive-deps': 'warn', 54 | // 'react-hooks/rules-of-hooks': 'error', 55 | 'react/button-has-type': [ 56 | 'error', 57 | { 58 | reset: true, 59 | }, 60 | ], 61 | 'react/jsx-curly-brace-presence': [ 62 | 'warn', 63 | { props: 'never', children: 'never' }, 64 | ], 65 | 'react/jsx-filename-extension': [ 66 | 'error', 67 | { extensions: ['.js', '.jsx', '.ts', '.tsx', 'mdx'] }, 68 | ], 69 | 'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }], 70 | 'react/jsx-pascal-case': 'off', 71 | 'react/jsx-props-no-spreading': 'off', 72 | 'react/jsx-uses-react': 'off', 73 | 74 | 'react/no-array-index-key': 'off', 75 | 'react/no-unescaped-entities': ['error', { forbid: ['>'] }], 76 | 'react/no-unknown-property': [ 77 | 'error', 78 | { ignore: ['css', 'cmdk-input-wrapper', 'tw'] }, 79 | ], 80 | 81 | // https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md 82 | 'react/no-unused-prop-types': 'off', 83 | // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-unescaped-entities.md 84 | 'react/prop-types': 'off', 85 | 'react/react-in-jsx-scope': 'off', 86 | 'react/require-default-props': 'off', 87 | }, 88 | }, 89 | ], 90 | }; 91 | -------------------------------------------------------------------------------- /config/eslint/bases/regexp.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom config base for projects that wants to enable regexp rules. 3 | * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases 4 | */ 5 | 6 | const regexpPatterns = { 7 | files: ['*.{js,jsx,ts,tsx}'], 8 | }; 9 | 10 | module.exports = { 11 | // @see https://github.com/ota-meshi/eslint-plugin-regexp 12 | extends: ['plugin:regexp/recommended'], 13 | overrides: [ 14 | { 15 | // To ensure best performance enable only on e2e test files 16 | extends: ['plugin:regexp/recommended'], 17 | files: regexpPatterns.files, 18 | rules: { 19 | 'prefer-regex-literals': 'off', 20 | 'regexp/prefer-result-array-groups': 'off', 21 | 'regexp/strict': 'off', 22 | }, 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /config/eslint/bases/rtl.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Opinionated config base for projects using react-testing-library 3 | * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases 4 | */ 5 | 6 | const rtlPatterns = { 7 | files: ['**/?(*.)+(test).{js,jsx,ts,tsx}'], 8 | }; 9 | 10 | module.exports = { 11 | env: { 12 | browser: true, 13 | es6: true, 14 | node: true, 15 | }, 16 | overrides: [ 17 | { 18 | // For performance enable react-testing-library only on test files 19 | files: rtlPatterns.files, 20 | extends: ['plugin:testing-library/react'], 21 | }, 22 | { 23 | files: ['**/test-utils.tsx'], 24 | rules: { 25 | '@typescript-eslint/explicit-module-boundary-types': 'off', 26 | 'import/export': 'off', 27 | }, 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /config/eslint/bases/typescript.cjs: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | 3 | /** 4 | * Custom config base for projects using typescript / javascript. 5 | * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases 6 | */ 7 | 8 | /** @type {import("eslint").Linter.Config} */ 9 | module.exports = { 10 | // Parser to use 11 | parser: '@typescript-eslint/parser', 12 | // Parser options 13 | parserOptions: { 14 | parser: '@typescript-eslint/parser', 15 | // project: path.join(__dirname, '../../../tsconfig.json'), 16 | }, 17 | // List of plugins to extend 18 | extends: [ 19 | // 'eslint:recommended', 20 | // 'plugin:import/recommended', 21 | 'plugin:import/typescript', 22 | 'plugin:@typescript-eslint/recommended', 23 | ], 24 | // List of plugins to use 25 | plugins: ['unused-imports'], 26 | // List of rules to use 27 | rules: { 28 | // TS rules 29 | '@typescript-eslint/ban-ts-comment': 'off', 30 | '@typescript-eslint/ban-ts-ignore': 'off', 31 | '@typescript-eslint/ban-types': 'off', 32 | '@typescript-eslint/camelcase': 'off', 33 | '@typescript-eslint/consistent-type-exports': 'off', 34 | '@typescript-eslint/consistent-type-imports': 'off', 35 | // not yet working with prettier + eslint (duplicate) 36 | // '@typescript-eslint/consistent-type-imports': [ 37 | // 'warn', 38 | // { 39 | // prefer: 'type-imports', 40 | // fixStyle: 'inline-type-imports', 41 | // }, 42 | // ], 43 | '@typescript-eslint/explicit-function-return-type': 'off', 44 | '@typescript-eslint/explicit-module-boundary-types': 'off', 45 | '@typescript-eslint/interface-name-prefix': 'off', 46 | '@typescript-eslint/naming-convention': 'off', 47 | '@typescript-eslint/no-empty-function': 'off', 48 | '@typescript-eslint/no-empty-interface': 'off', 49 | '@typescript-eslint/no-explicit-any': 'off', 50 | '@typescript-eslint/no-namespace': 'off', 51 | '@typescript-eslint/no-non-null-assertion': 'off', 52 | '@typescript-eslint/no-shadow': ['error'], 53 | '@typescript-eslint/no-unused-vars': 'off', 54 | '@typescript-eslint/no-use-before-define': 'off', 55 | '@typescript-eslint/no-useless-constructor': 'off', 56 | '@typescript-eslint/no-var-requires': 'off', 57 | }, 58 | // Overrides for specific files 59 | overrides: [ 60 | { 61 | files: ['packages/cli/**'], 62 | rules: { 63 | '@typescript-eslint/no-shadow': 'off', 64 | }, 65 | }, 66 | { 67 | files: ['*.mjs'], 68 | parserOptions: { 69 | ecmaVersion: 'latest', 70 | sourceType: 'module', 71 | }, 72 | rules: { 73 | '@typescript-eslint/explicit-module-boundary-types': 'off', 74 | '@typescript-eslint/consistent-type-exports': 'off', 75 | '@typescript-eslint/consistent-type-imports': 'off', 76 | }, 77 | }, 78 | { 79 | // commonjs or assumed 80 | files: ['*.js', '*.cjs'], 81 | parser: 'espree', 82 | parserOptions: { 83 | ecmaVersion: 2020, 84 | }, 85 | rules: { 86 | '@typescript-eslint/ban-ts-comment': 'off', 87 | '@typescript-eslint/no-explicit-any': 'off', 88 | '@typescript-eslint/no-var-requires': 'off', 89 | '@typescript-eslint/explicit-module-boundary-types': 'off', 90 | '@typescript-eslint/consistent-type-exports': 'off', 91 | '@typescript-eslint/consistent-type-imports': 'off', 92 | }, 93 | }, 94 | ], 95 | settings: { 96 | 'import/resolver': { 97 | typescript: {}, 98 | }, 99 | }, 100 | }; 101 | -------------------------------------------------------------------------------- /config/eslint/bases/unicorn.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:unicorn/recommended'], 3 | plugins: ['unicorn'], 4 | rules: { 5 | 'unicorn/consistent-destructuring': 'off', 6 | 'unicorn/consistent-function-scoping': [ 7 | 'error', 8 | { 9 | checkArrowFunctions: false, 10 | }, 11 | ], 12 | 'unicorn/prefer-module': 'off', 13 | 'unicorn/expiring-todo-comments': 'off', 14 | 'unicorn/filename-case': 'off', 15 | 'unicorn/no-array-callback-reference': 'off', 16 | 'unicorn/no-array-for-each': 'off', 17 | 'unicorn/no-array-reduce': 'off', 18 | 'unicorn/no-for-loop': 'off', 19 | 'unicorn/no-null': 'off', 20 | 'unicorn/no-thenable': 'off', 21 | 'unicorn/prefer-optional-catch-binding': 'off', 22 | 'unicorn/prefer-regexp-test': 'off', 23 | // Spread syntax causes non-deterministic type errors 24 | 'unicorn/prefer-spread': 'off', 25 | 26 | // TypeScript doesn't like the for-of loop this rule fixes to 27 | 'unicorn/prevent-abbreviations': 'off', 28 | }, 29 | overrides: [ 30 | { 31 | files: ['packages/cli/**'], 32 | rules: { 33 | 'unicorn/prefer-node-protocol': 'off', 34 | 'unicorn/no-process-exit': 'off', 35 | 'unicorn/prefer-top-level-await': 'off', 36 | 'unicorn/prefer-string-replace-all': 'off', 37 | }, 38 | }, 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /config/eslint/bases/vitest.cjs: -------------------------------------------------------------------------------- 1 | const vitest = require('@vitest/eslint-plugin'); 2 | 3 | module.exports = { 4 | plugins: ['vitest'], 5 | overrides: [ 6 | { 7 | files: ['**/?(*.)+(test|spec).{js,jsx,ts,tsx}'], 8 | rules: { 9 | ...vitest.configs.recommended.rules, 10 | '@typescript-eslint/ban-ts-comment': 'off', 11 | '@typescript-eslint/no-empty-function': 'off', 12 | '@typescript-eslint/no-explicit-any': 'off', 13 | '@typescript-eslint/no-non-null-assertion': 'off', 14 | '@typescript-eslint/no-object-literal-type-assertion': 'off', 15 | 'import/default': 'off', 16 | 'import/namespace': 'off', 17 | 'import/no-duplicates': 'off', 18 | 'import/no-named-as-default-member': 'off', 19 | }, 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /config/eslint/helpers/getDefaultIgnorePatterns.cjs: -------------------------------------------------------------------------------- 1 | const getDefaultIgnorePatterns = () => { 2 | return [ 3 | // Hacky way to silence @yarnpkg/doctor about node_modules detection 4 | `**/${'node'}_modules`, 5 | '.cache', 6 | '**/.cache', 7 | '**/build', 8 | '**/dist', 9 | '**/.storybook', 10 | '**/storybook-static', 11 | '**/vault', 12 | ]; 13 | }; 14 | 15 | module.exports = { 16 | getDefaultIgnorePatterns, 17 | }; 18 | -------------------------------------------------------------------------------- /config/eslint/helpers/getPrettierConfig.cjs: -------------------------------------------------------------------------------- 1 | const prettierBaseConfig = require('../prettier.base.config.cjs'); 2 | 3 | const getPrettierConfig = () => { 4 | return prettierBaseConfig; 5 | }; 6 | 7 | module.exports = { 8 | getPrettierConfig, 9 | }; 10 | -------------------------------------------------------------------------------- /config/eslint/prettier.base.config.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Config} 3 | */ 4 | module.exports = { 5 | // Set the line ending to `lf`. 6 | // https://prettier.io/docs/en/options.html#end-of-line 7 | endOfLine: 'lf', 8 | 9 | // Do not add semicolons at the end of statements. 10 | // https://prettier.io/docs/en/options.html#semicolons 11 | semi: true, 12 | 13 | // Use single quotes for string literals. 14 | // https://prettier.io/docs/en/options.html#quotes 15 | singleQuote: true, 16 | 17 | // Set the tab width to 2 spaces. 18 | // https://prettier.io/docs/en/options.html#tab-width 19 | tabWidth: 2, 20 | 21 | // Add trailing commas for object and array literals in ES5-compatible mode. 22 | // https://prettier.io/docs/en/options.html#trailing-commas 23 | trailingComma: 'es5', 24 | 25 | // Define the order in which imports should be sorted, with specific patterns for React, Next.js, third-party modules, local modules, and relative imports. 26 | importOrder: [ 27 | '^(react/(.*)$)|^(react$)', 28 | '^(next/(.*)$)|^(next$)', 29 | '', 30 | '', 31 | '^types$', 32 | '^@/types/(.*)$', 33 | '^@/config/(.*)$', 34 | '^@/lib/(.*)$', 35 | '^@/hooks/(.*)$', 36 | '^@/components/ui/(.*)$', 37 | '^@/components/(.*)$', 38 | '^@/registry/(.*)$', 39 | '^@/styles/(.*)$', 40 | '^@/app/(.*)$', 41 | '', 42 | '^[./]', 43 | '', 44 | '^[./]', 45 | '^react', 46 | '^next', 47 | '^@/', 48 | '', 49 | ], 50 | 51 | // Specify the parser plugins to use for import sorting. 52 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], 53 | 54 | // Combine type-only imports with value imports. 55 | importOrderTypeScriptVersion: '5.1.6', 56 | 57 | // Use the `@ianvs/prettier-plugin-sort-imports` plugin to sort imports. 58 | // https://github.com/ianvs/prettier-plugin-sort-imports 59 | plugins: ['@ianvs/prettier-plugin-sort-imports'], 60 | }; 61 | -------------------------------------------------------------------------------- /config/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "emitDeclarationOnly": true, 7 | "noEmit": false 8 | }, 9 | "exclude": [ 10 | "../node_modules", 11 | "../**/node_modules", 12 | "../dist", 13 | "../**/dist", 14 | "../**/__tests__/**", 15 | "../**/*.stories.*", 16 | "../**/*.spec*", 17 | "../**/*fixture*", 18 | "../**/*template*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /config/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": {} 4 | } 5 | -------------------------------------------------------------------------------- /config/tsup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module,@typescript-eslint/no-shadow */ 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import { defineConfig } from 'tsup'; 5 | 6 | const silent = false; 7 | 8 | const PACKAGE_ROOT_PATH = process.cwd(); 9 | const INPUT_FILE_PATH = path.join(PACKAGE_ROOT_PATH, 'src/index.ts'); 10 | const INPUT_FILE = fs.existsSync(INPUT_FILE_PATH) 11 | ? INPUT_FILE_PATH 12 | : path.join(PACKAGE_ROOT_PATH, 'src/index.tsx'); 13 | 14 | export default defineConfig((opts) => { 15 | return { 16 | ...opts, 17 | entry: [INPUT_FILE], 18 | format: ['cjs', 'esm'], 19 | dts: true, 20 | sourcemap: true, 21 | clean: true, 22 | 23 | ...(silent 24 | ? { 25 | silent: true, 26 | onSuccess: async () => { 27 | if (opts.watch) { 28 | console.info('Watching for changes...'); 29 | return; 30 | } 31 | 32 | console.info('Build succeeded!'); 33 | }, 34 | } 35 | : {}), 36 | }; 37 | }); 38 | -------------------------------------------------------------------------------- /config/turbowatch.config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import { GlobSync } from 'glob'; 3 | import { defineConfig, Expression } from 'turbowatch'; 4 | 5 | const foundPackageJson = new GlobSync('packages/*/package.json').found; 6 | 7 | type PathToPackageNameMap = Map; 8 | 9 | const allPackages = foundPackageJson.reduce( 10 | (acc, current) => { 11 | try { 12 | const packageJson = readFileSync(current, 'utf8'); 13 | const packageJsonParsed = JSON.parse(packageJson) as { 14 | dependencies: Record; 15 | name: string | undefined; 16 | }; 17 | 18 | const packageName = packageJsonParsed.name; 19 | 20 | if (!packageName) { 21 | return acc; 22 | } 23 | 24 | acc.set(current, packageName); 25 | return acc; 26 | } catch (_) {} 27 | 28 | return acc; 29 | }, 30 | new Map() 31 | ); 32 | 33 | const dirList = [...allPackages.keys()].map( 34 | (dir) => ['dirname', dir.replace('/package.json', '')] satisfies Expression 35 | ); 36 | 37 | export default defineConfig({ 38 | project: process.cwd(), 39 | triggers: [ 40 | { 41 | expression: [ 42 | 'allof', 43 | ['not', ['anyof', ['dirname', 'node_modules'], ['dirname', 'dist']]], 44 | ['anyof', ...dirList], 45 | [ 46 | 'anyof', 47 | ['match', '*.ts', 'basename'], 48 | ['match', '*.tsx', 'basename'], 49 | ['match', '*.js', 'basename'], 50 | ], 51 | ], 52 | interruptible: true, 53 | name: 'build', 54 | onChange: async ({ spawn, files, abortSignal }) => { 55 | const changedPackages = new Set(); 56 | for (const file of files) { 57 | const pkgJsonPath = file.name 58 | .replace(`${process.cwd()}/`, '') 59 | .replace(/\/src\/.*/, '/package.json'); 60 | 61 | const packageName = allPackages.get(pkgJsonPath); 62 | 63 | if (!packageName) { 64 | continue; 65 | } 66 | 67 | changedPackages.add(packageName); 68 | } 69 | 70 | if (changedPackages.size === 0) { 71 | return; 72 | } 73 | 74 | await spawn`turbo run build --filter=${[...changedPackages].join(',')}`; 75 | if (abortSignal?.aborted) return; 76 | }, 77 | }, 78 | ], 79 | }); 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "MIT", 4 | "workspaces": [ 5 | "packages/**" 6 | ], 7 | "scripts": { 8 | "build": "yarn g:build", 9 | "build:watch": "ROARR_LOG=true turbowatch ./config/turbowatch.config.ts | roarr", 10 | "brl": "yarn g:brl", 11 | "check:install": "yarn dlx @yarnpkg/doctor@4.0.0-rc.10 --configFileName config/.ncurc.yml packages", 12 | "clean:turbo": "rimraf --glob '**/.turbo' '**/turbo-*.log' && turbo daemon clean", 13 | "deps:check": "npx npm-check-updates@latest --configFileName config/ncurc.yml --workspaces --root --mergeConfig", 14 | "deps:update": "npx npm-check-updates@latest --configFileName config/ncurc.yml -u --workspaces --root --mergeConfig", 15 | "lint": "yarn g:lint", 16 | "lint:fix": "yarn g:lint:fix", 17 | "test": "yarn g:test", 18 | "typecheck": "yarn g:typecheck", 19 | "typedoc": "npx typedoc --options scripts/typedoc.json", 20 | "release": "yarn build && yarn changeset publish", 21 | "release:next": "yarn build && yarn changeset publish --tag next", 22 | "g:brl": "turbo --filter \"./packages/**\" brl --no-daemon", 23 | "g:build": "turbo --filter \"./packages/**\" build --no-daemon", 24 | "g:build:watch": "yarn build:watch", 25 | "g:changeset": "changeset", 26 | "g:clean": "yarn clean:turbo && turbo --filter \"./packages/**\" clean --no-daemon", 27 | "g:lint": "turbo --filter \"./packages/**\" lint --no-daemon", 28 | "g:lint:fix": "turbo lint:fix --no-daemon", 29 | "g:test": "turbo --filter \"./packages/**\" test --no-daemon", 30 | "g:test:watch": "turbo --filter \"./packages/**\" test:watch --no-daemon", 31 | "g:test:cov": "yarn g:test --coverage", 32 | "g:test:covw": "yarn g:test:cov --watch", 33 | "g:test:covwa": "yarn g:test:cov --watchAll", 34 | "g:test:wa": "yarn g:test --watchAll", 35 | "g:typecheck": "turbo typecheck --no-daemon", 36 | "nuke:node_modules": "rimraf --glob '**/node_modules'", 37 | "p:brl": "cd $INIT_CWD && barrelsby -d $INIT_CWD/src -D -l all -q -e '.*(fixture|template|spec|__tests__).*'", 38 | "p:brl:below": "cd $INIT_CWD && barrelsby -d $INIT_CWD/src -D -l below -q -e '.*(fixture|template|spec|__tests__).*'", 39 | "p:build": "cd $INIT_CWD && yarn p:tsup", 40 | "p:build:watch": "cd $INIT_CWD && yarn p:tsup --watch", 41 | "p:clean": "cd $INIT_CWD && rimraf dist", 42 | "p:lint": "eslint $INIT_CWD/src --color", 43 | "p:lint:fix": "eslint $INIT_CWD/src --color --fix", 44 | "p:tsup": "cd $INIT_CWD && tsup --config=${PROJECT_CWD}/config/tsup.config.ts", 45 | "p:test": "cd $INIT_CWD && vitest run --config=${PROJECT_CWD}/vitest.config.ts --passWithNoTests", 46 | "p:typecheck": "cd $INIT_CWD && tsc --noEmit --emitDeclarationOnly false" 47 | }, 48 | "devDependencies": { 49 | "@changesets/cli": "^2.27.1", 50 | "@changesets/get-github-info": "^0.6.0", 51 | "@changesets/parse": "^0.4.0", 52 | "@changesets/types": "^6.0.0", 53 | "@dword-design/eslint-plugin-import-alias": "^4.0.9", 54 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 55 | "@roarr/cli": "^5.12.4", 56 | "@swc/core": "1.3.100", 57 | "@testing-library/jest-dom": "^6.1.5", 58 | "@testing-library/react": "^14.1.2", 59 | "@testing-library/react-hooks": "^8.0.1", 60 | "@testing-library/user-event": "^14.5.1", 61 | "@types/glob": "8.1.0", 62 | "@types/node": "^20.10.4", 63 | "@types/react": "18.2.42", 64 | "@types/react-dom": "18.2.17", 65 | "@typescript-eslint/eslint-plugin": "^6.13.2", 66 | "@typescript-eslint/parser": "^6.13.2", 67 | "@vitest/eslint-plugin": "1.1.25", 68 | "app-root-path": "^3.1.0", 69 | "barrelsby": "^2.8.1", 70 | "concurrently": "^8.2.2", 71 | "eslint": "8.55.0", 72 | "eslint-config-prettier": "^9.1.0", 73 | "eslint-config-turbo": "^1.11.0", 74 | "eslint-import-resolver-typescript": "^3.6.1", 75 | "eslint-plugin-babel": "5.3.1", 76 | "eslint-plugin-chai-friendly": "^0.7.2", 77 | "eslint-plugin-cypress": "^2.15.1", 78 | "eslint-plugin-import": "^2.29.0", 79 | "eslint-plugin-jsx-a11y": "^6.8.0", 80 | "eslint-plugin-mdx": "^2.2.0", 81 | "eslint-plugin-prettier": "^5.0.1", 82 | "eslint-plugin-promise": "^6.1.1", 83 | "eslint-plugin-react": "^7.33.2", 84 | "eslint-plugin-react-compiler": "19.0.0-beta-decd7b8-20250118", 85 | "eslint-plugin-react-hooks": "^4.6.0", 86 | "eslint-plugin-regexp": "^2.1.2", 87 | "eslint-plugin-testing-library": "^6.2.0", 88 | "eslint-plugin-unicorn": "^49.0.0", 89 | "eslint-plugin-unused-imports": "^3.0.0", 90 | "eslint-plugin-vitest": "0.5.4", 91 | "jotai": "^2.6.0", 92 | "jsdom": "26.0.0", 93 | "prettier": "^3.1.0", 94 | "react": "^18.2.0", 95 | "react-dom": "^18.2.0", 96 | "react-is": "18.2.0", 97 | "react-test-renderer": "18.2.0", 98 | "rimraf": "^5.0.5", 99 | "tsup": "8.0.1", 100 | "turbo": "^1.11.0", 101 | "turbowatch": "2.29.4", 102 | "typedoc": "^0.25.4", 103 | "typescript": "5.7.3", 104 | "vitest": "3.0.5" 105 | }, 106 | "packageManager": "yarn@4.6.0", 107 | "engines": { 108 | "node": ">=18.12.0", 109 | "yarn": ">=1.22.0", 110 | "npm": "please-use-yarn" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /packages/jotai-x/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | __test-utils__ 3 | __mocks__ 4 | -------------------------------------------------------------------------------- /packages/jotai-x/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # jotai-x 2 | 3 | ## 2.3.2 4 | 5 | ### Patch Changes 6 | 7 | - [`431e843`](https://github.com/udecode/jotai-x/commit/431e8435e18d8ef84af34a95bb3802b3625a3707) by [@zbeyens](https://github.com/zbeyens) – Fix: remove default infiniteRenderDetectionLimit 8 | 9 | ## 2.3.1 10 | 11 | ### Patch Changes 12 | 13 | - [#30](https://github.com/udecode/jotai-x/pull/30) by [@12joan](https://github.com/12joan) – Fix: Return value of `useStore` is not memorized 14 | 15 | ## 2.3.0 16 | 17 | ### Minor Changes 18 | 19 | - [#28](https://github.com/udecode/jotai-x/pull/28) by [@zbeyens](https://github.com/zbeyens) – 20 | - Rename `useStoreValue` to `useAtomStoreValue` 21 | - Rename `useStoreSet` to `useAtomStoreSet` 22 | - Rename `useStoreState` to `useAtomStoreState` 23 | 24 | ### Patch Changes 25 | 26 | - [#28](https://github.com/udecode/jotai-x/pull/28) by [@zbeyens](https://github.com/zbeyens) – Fix `deps` param 27 | 28 | ## 2.2.0 29 | 30 | ### Minor Changes 31 | 32 | - [`c0ec41f`](https://github.com/udecode/jotai-x/commit/c0ec41f1f405f70d59621f675aabf17045abb4da) by [@zbeyens](https://github.com/zbeyens) – 33 | - `createAtomStore` now returns new utility hooks to provide a more direct way to access store atoms without having to call `useStore()` first: 34 | - `useState(key, storeOptions)` - Get/set state for a specific key 35 | - `useValue(key, options, deps)` - Get value for a specific key with optional selector and equality function 36 | - `useSet(key, storeOptions)` - Get setter for a specific key 37 | 38 | ## 2.1.2 39 | 40 | ### Patch Changes 41 | 42 | - Add `suppressWarnings` option to `createAtomStore` options to control store-level warning behavior when accessing store outside provider 43 | 44 | ## 2.1.1 45 | 46 | ### Patch Changes 47 | 48 | - [#22](https://github.com/udecode/jotai-x/pull/22) by [@yf-yang](https://github.com/yf-yang) – Add hooks `useStoreValue`, `useStoreSet`, `useStoreState`, `useStoreAtomValue`, `useStoreSetAtom`, `useStoreAtomState` to ease react-compiler eslint plugin complains 49 | 50 | ## 2.1.0 51 | 52 | ### Minor Changes 53 | 54 | - [#20](https://github.com/udecode/jotai-x/pull/20) by [@yf-yang](https://github.com/yf-yang) – Add alternative selector and equalityFn support to `useValue` 55 | 56 | ## 2.0.0 57 | 58 | ### Major Changes 59 | 60 | - [#17](https://github.com/udecode/jotai-x/pull/17) by [@yf-yang](https://github.com/yf-yang) – 1. Rename `get` to `useValue`, `set` to `useSet`, `use` to `useState`. 2. `useStore().store()` -> `useStore().store`. 3. `useStore().get.value(option)` and `useStore().set.value(option)`'s `option` parameters are no longer supported. Pass the option to `useStore()` instead. 4. Rename APIs: 61 | - `useStore().get.key()` -> `useStore().useKeyValue()` 62 | - `useStore().get.key()` -> `useStore().useValue('key')` 63 | - `useStore().set.key()` -> `useStore().useSetKey()` 64 | - `useStore().set.key()` -> `useStore().useSet('key')` 65 | - `useStore().use.key()` -> `useStore().useKeyState()` 66 | - `useStore().use.key()` -> `useStore().useState('key')` 67 | - `useStore().get.atom(atomConfig)` -> `useStore().useAtomValue(atomConfig)` 68 | - `useStore().set.atom(atomConfig)` -> `useStore().useSetAtom(atomConfig)` 69 | - `useStore().use.atom(atomConfig)` -> `useStore().useAtomState(atomConfig)` 70 | 5. More APIs to directly get/set/subscribe atom states: 71 | - `useStore().getKey()` 72 | - `useStore().get('key')` 73 | - `useStore().setKey(...args)` 74 | - `useStore().set('key', ...args)` 75 | - `useStore().subscribeKey(...args)` 76 | - `useStore().subscribe('key', ...args)` 77 | - `useStore().getAtom(atomConfig)` 78 | - `useStore().setAtom(atomConfig, ...args)` 79 | - `useStore().subscribeAtom(atomConfig, ...args)` 80 | 81 | ## 1.2.4 82 | 83 | ### Patch Changes 84 | 85 | - [`24a1de7`](https://github.com/udecode/jotai-x/commit/24a1de747cea2ecc89b3005877527a7805a0eb87) by [@zbeyens](https://github.com/zbeyens) – doc 86 | 87 | ## 1.2.3 88 | 89 | ### Patch Changes 90 | 91 | - [#11](https://github.com/udecode/jotai-x/pull/11) by [@12joan](https://github.com/12joan) – Do not render jotai's Provider component as part of jotai-x's provider. Jotai's Provider is unnecessary and interferes with vanilla jotai atoms. 92 | 93 | - [#13](https://github.com/udecode/jotai-x/pull/13) by [@zbeyens](https://github.com/zbeyens) – use client in createAtomProvider 94 | 95 | ## 1.2.2 96 | 97 | ### Patch Changes 98 | 99 | - [#8](https://github.com/udecode/jotai-x/pull/8) by [@zbeyens](https://github.com/zbeyens) – Fix React imports for SSR 100 | 101 | ## 1.2.1 102 | 103 | ### Patch Changes 104 | 105 | - [#6](https://github.com/udecode/jotai-x/pull/6) by [@12joan](https://github.com/12joan) – Fix: Provider prop types expect atoms instead of values for stores created with custom atoms 106 | 107 | ## 1.2.0 108 | 109 | ### Minor Changes 110 | 111 | - [#4](https://github.com/udecode/jotai-x/pull/4) by [@12joan](https://github.com/12joan) – Add `warnIfNoStore` option to `UseAtomOptions` 112 | 113 | ## 1.1.0 114 | 115 | ### Minor Changes 116 | 117 | - [#2](https://github.com/udecode/jotai-x/pull/2) by [@12joan](https://github.com/12joan) – 118 | - Atoms other than `atom` can now be passed in the `initialState` argument to `createAtomStore`. Primitive values use `atom` by default 119 | - Added an `extend` option to `createAtomStore` that lets you add derived atoms to the store 120 | - New accessors on `UseStoreApi` 121 | - `useMyStore().store()` returns the `JotaiStore` for the current context, or undefined if no store exists 122 | - `useMyStore().{get,set,use}.atom(someAtom)` accesses `someAtom` through the store 123 | - Types: remove exports for some internal types 124 | - `GetRecord` 125 | - `SetRecord` 126 | - `UseRecord` 127 | 128 | ## 1.0.1 129 | 130 | ### Patch Changes 131 | 132 | - [`099d310`](https://github.com/udecode/jotai-x/commit/099d310cdec35767aeaa2616634cb2502ccbc5e7) by [@zbeyens](https://github.com/zbeyens) – Fix: add React as peer dependency. 133 | -------------------------------------------------------------------------------- /packages/jotai-x/README.md: -------------------------------------------------------------------------------- 1 | # Jotai X 2 | 3 | An extension for [Jotai](https://github.com/pmndrs/jotai) that auto-generates type-safe hooks and utilities for your state. Built with TypeScript and React in mind. 4 | 5 | ## Features 6 | 7 | - Auto-generated type-safe hooks for each state field 8 | - Simple patterns: `useValue('name')` and `useSet('name', value)` 9 | - Extend your store with computed values using `extend` 10 | - Built-in support for hydration, synchronization, and scoped providers 11 | 12 | ## Why 13 | 14 | Built on top of `jotai`, `jotai-x` offers a better developer experience with less boilerplate. Create and interact with stores faster using a more intuitive API. 15 | 16 | > Looking for global state management instead of React Context-based state? Check out [Zustand X](https://github.com/udecode/zustand-x) - same API, different state model. 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pnpm add jotai jotai-x 22 | ``` 23 | 24 | ## Quick Start 25 | 26 | Here's how to create a simple store: 27 | 28 | ```tsx 29 | import { createAtomStore } from 'jotai-x'; 30 | 31 | // Create a store with an initial state 32 | // Store name is used as prefix for all returned hooks (e.g., `useAppStore`, `useAppValue` for `name: 'app'`) 33 | const { useAppStore, useAppValue, useAppSet, useAppState, AppProvider } = 34 | createAtomStore( 35 | { 36 | name: 'JotaiX', 37 | stars: 0, 38 | }, 39 | { 40 | name: 'app', 41 | } 42 | ); 43 | 44 | // Use it in your components 45 | function RepoInfo() { 46 | const name = useAppValue('name'); 47 | const stars = useAppValue('stars'); 48 | 49 | return ( 50 |
51 |

{name}

52 |

{stars} stars

53 |
54 | ); 55 | } 56 | 57 | function AddStarButton() { 58 | const setStars = useAppSet('stars'); 59 | 60 | return ; 61 | } 62 | ``` 63 | 64 | ## Core Concepts 65 | 66 | ### Store Configuration 67 | 68 | The store is where everything begins. Configure it with type-safe options: 69 | 70 | ```ts 71 | import { createAtomStore } from 'jotai-x'; 72 | 73 | // Types are inferred, including options 74 | const { useUserValue, useUserSet, useUserState, UserProvider } = 75 | createAtomStore( 76 | { 77 | name: 'Alice', 78 | loggedIn: false, 79 | }, 80 | { 81 | name: 'user', 82 | delay: 100, // Optional delay for state updates 83 | effect: EffectComponent, // Optional effect component 84 | extend: (atoms) => ({ 85 | // Optional derived atoms 86 | intro: atom((get) => `My name is ${get(atoms.name)}`), 87 | }), 88 | infiniteRenderDetectionLimit: 100, // Optional render detection limit 89 | } 90 | ); 91 | ``` 92 | 93 | Available options: 94 | 95 | ```ts 96 | { 97 | name: string; 98 | delay?: number; 99 | effect?: React.ComponentType; 100 | extend?: (atoms: Atoms) => DerivedAtoms; 101 | infiniteRenderDetectionLimit?: number; 102 | } 103 | ``` 104 | 105 | ### Store API 106 | 107 | The `createAtomStore` function returns an object with the following: 108 | 109 | ```ts 110 | const { 111 | // Store name used as prefix 112 | name: string, 113 | 114 | // Store hook returning all utilities 115 | useAppStore: () => StoreApi, 116 | 117 | // Direct hooks for state management 118 | useAppValue: (key: string, options?) => Value, 119 | useAppSet: (key: string) => SetterFn, 120 | useAppState: (key: string) => [Value, SetterFn], 121 | 122 | // Provider component 123 | AppProvider: React.FC, 124 | 125 | // Record of all atoms in the store 126 | appStore: { 127 | atom: Record 128 | } 129 | } = createAtomStore({ ... }, { name: 'app' }); 130 | ``` 131 | 132 | ### Reading and Writing State 133 | 134 | There are three ways to interact with the store state: 135 | 136 | #### 1. Hooks (Recommended) 137 | 138 | The most straightforward way using hooks returned by `createAtomStore`: 139 | 140 | ```ts 141 | // Get value 142 | const name = useAppValue('name'); 143 | const stars = useAppValue('stars'); 144 | 145 | // Set value 146 | const setName = useAppSet('name'); 147 | const setStars = useAppSet('stars'); 148 | 149 | // Get both value and setter 150 | const [name, setName] = useAppState('name'); 151 | const [stars, setStars] = useAppState('stars'); 152 | 153 | // With selector and deps 154 | const upperName = useAppValue('name', { 155 | selector: (name) => name.toUpperCase(), 156 | }, []); 157 | ``` 158 | 159 | #### 2. Store Instance Methods 160 | 161 | Using the store instance from `useAppStore()`: 162 | 163 | ```ts 164 | const store = useAppStore(); 165 | 166 | // By key 167 | store.get('name'); // Get value 168 | store.set('name', 'value'); // Set value 169 | store.subscribe('name', (value) => console.log(value)); // Subscribe to changes 170 | 171 | // Direct access 172 | store.getName(); // Get value 173 | store.setName('value'); // Set value 174 | store.subscribeName((value) => console.log(value)); // Subscribe to changes 175 | ``` 176 | 177 | #### 3. Raw Atom Access 178 | 179 | For advanced use cases, you can work directly with atoms: 180 | 181 | ```ts 182 | const store = useAppStore(); 183 | 184 | // Access atoms 185 | store.getAtom(someAtom); // Get atom value 186 | store.setAtom(someAtom, 'value'); // Set atom value 187 | store.subscribeAtom(someAtom, (value) => {}); // Subscribe to atom 188 | 189 | // Access underlying Jotai store 190 | const jotaiStore = store.store; 191 | ``` 192 | 193 | ### Hook API Reference 194 | 195 | #### `useValue(key, options?)` 196 | 197 | Subscribe to a single value with optional selector and deps: 198 | 199 | ```ts 200 | // Basic usage 201 | const name = useAppValue('name'); 202 | 203 | // With selector 204 | const upperName = useAppValue('name', { 205 | selector: (name) => name.toUpperCase(), 206 | }, [] // if selector is not memoized, provide deps array 207 | ); 208 | 209 | // With equality function 210 | const name = useAppValue('name', { 211 | selector: (name) => name, 212 | equalityFn: (prev, next) => prev.length === next.length 213 | }, []); 214 | ``` 215 | 216 | #### `useSet(key)` 217 | 218 | Get a setter function for a value: 219 | 220 | ```ts 221 | const setName = useAppSet('name'); 222 | setName('new value'); 223 | setName((prev) => prev.toUpperCase()); 224 | ``` 225 | 226 | #### `useState(key)` 227 | 228 | Get both value and setter, like React's `useState`: 229 | 230 | ```tsx 231 | function UserForm() { 232 | const [name, setName] = useAppState('name'); 233 | const [email, setEmail] = useAppState('email'); 234 | 235 | return ( 236 |
237 | setName(e.target.value)} /> 238 | setEmail(e.target.value)} /> 239 |
240 | ); 241 | } 242 | ``` 243 | 244 | ### Provider-Based Store Hydration 245 | 246 | The provider component handles store initialization and state synchronization: 247 | 248 | ```tsx 249 | type ProviderProps = { 250 | // Initial values for atoms, hydrated once on mount 251 | initialValues?: Partial; 252 | 253 | // Dynamic values for controlled state 254 | ...Partial; 255 | 256 | // Optional custom store instance 257 | store?: JotaiStore; 258 | 259 | // Optional scope for nested providers 260 | scope?: string; 261 | 262 | // Optional key to reset the store 263 | resetKey?: any; 264 | 265 | children: React.ReactNode; 266 | }; 267 | 268 | function App() { 269 | return ( 270 | 286 | 287 | 288 | ); 289 | } 290 | ``` 291 | 292 | ### Scoped Providers 293 | 294 | Create multiple instances of the same store with different scopes: 295 | 296 | ```tsx 297 | function App() { 298 | return ( 299 | 300 | 301 | 302 | 303 | 304 | ); 305 | } 306 | 307 | function UserProfile() { 308 | // Get parent scope 309 | const parentName = useUserValue('name', { scope: 'parent' }); 310 | // Get closest scope 311 | const name = useUserValue('name'); 312 | } 313 | ``` 314 | 315 | ### Derived Atoms 316 | 317 | Two ways to create derived atoms: 318 | 319 | ```ts 320 | // 1. Using extend 321 | const { useUserValue } = createAtomStore( 322 | { 323 | name: 'Alice', 324 | }, 325 | { 326 | name: 'user', 327 | extend: (atoms) => ({ 328 | intro: atom((get) => `My name is ${get(atoms.name)}`), 329 | }), 330 | } 331 | ); 332 | 333 | // Access the derived value using the store name 334 | const intro = useUserValue('intro'); 335 | 336 | // 2. External atoms 337 | const { userStore, useUserStore } = createAtomStore( 338 | { 339 | name: 'Alice', 340 | }, 341 | { 342 | name: 'user', 343 | } 344 | ); 345 | 346 | // Create an external atom 347 | const introAtom = atom((get) => `My name is ${get(userStore.atom.name)}`); 348 | 349 | // Create a writable external atom 350 | const countAtom = atom( 351 | (get) => get(userStore.atom.name).length, 352 | (get, set, newCount: number) => { 353 | set(userStore.atom.name, 'A'.repeat(newCount)); 354 | } 355 | ); 356 | 357 | // Get the store instance 358 | const store = useUserStore(); 359 | 360 | // Access external atoms using store-based atom hooks 361 | const intro = useAtomValue(store, introAtom); // Read-only atom 362 | const [count, setCount] = useAtomState(store, countAtom); // Read-write atom 363 | const setCount2 = useSetAtom(store, countAtom); // Write-only 364 | 365 | // With selector and deps 366 | const upperIntro = useAtomValue( 367 | store, 368 | introAtom, 369 | (intro) => intro.toUpperCase(), 370 | [] // Optional deps array for selector 371 | ); 372 | 373 | // With selector and equality function 374 | const intro2 = useAtomValue( 375 | store, 376 | introAtom, 377 | (intro) => intro, 378 | (prev, next) => prev.length === next.length // Optional equality function 379 | ); 380 | ``` 381 | 382 | The store-based atom hooks provide more flexibility when working with external atoms: 383 | 384 | - `useAtomValue(store, atom, selector?, equalityFnOrDeps?, deps?)`: Subscribe to a read-only atom value 385 | - `selector`: Transform the atom value (must be memoized or use deps) 386 | - `equalityFnOrDeps`: Custom comparison function or deps array 387 | - `deps`: Dependencies array when using both selector and equalityFn 388 | - `useSetAtom(store, atom)`: Get a setter function for a writable atom 389 | - `useAtomState(store, atom)`: Get both value and setter for a writable atom, like React's `useState` 390 | 391 | ## Troubleshooting 392 | 393 | ### Infinite Render Detection 394 | 395 | When using value hooks with selectors, ensure they are memoized: 396 | 397 | ```tsx 398 | // ❌ Wrong - will cause infinite renders 399 | useUserValue('name', { selector: (name) => name.toUpperCase() }); 400 | 401 | // ✅ Correct - memoize with useCallback 402 | const selector = useCallback((name) => name.toUpperCase(), []); 403 | useUserValue('name', { selector }); 404 | 405 | // ✅ Correct - provide deps array 406 | useUserValue('name', { selector: (name) => name.toUpperCase() }, []); 407 | 408 | // ✅ Correct - no selector 409 | useUserValue('name'); 410 | ``` 411 | 412 | ## Migration from v1 to v2 413 | 414 | ```ts 415 | // Before 416 | const { useAppStore } = createAtomStore({ name: 'Alice' }, { name: 'app' }); 417 | const name = useAppStore().get.name(); 418 | const setName = useAppStore().set.name(); 419 | const [name, setName] = useAppStore().use.name(); 420 | 421 | // Now 422 | const { useAppStore, useAppValue, useAppSet, useAppState } = createAtomStore({ name: 'Alice' }, { name: 'app' }); 423 | const name = useAppValue('name'); 424 | const setName = useAppSet('name'); 425 | const [name, setName] = useAppState('name'); 426 | ``` 427 | 428 | ## License 429 | 430 | [MIT](./LICENSE) 431 | -------------------------------------------------------------------------------- /packages/jotai-x/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jotai-x", 3 | "version": "2.3.2", 4 | "description": "Jotai store factory for a best-in-class developer experience.", 5 | "license": "MIT", 6 | "homepage": "https://jotai-x.udecode.dev/", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/udecode/jotai-x.git", 10 | "directory": "packages/jotai-x" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/udecode/jotai-x/issues" 14 | }, 15 | "sideEffects": false, 16 | "main": "dist/index.js", 17 | "module": "dist/index.mjs", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist/**/*" 21 | ], 22 | "exports": { 23 | ".": { 24 | "types": "./dist/index.d.ts", 25 | "import": "./dist/index.mjs", 26 | "module": "./dist/index.mjs", 27 | "require": "./dist/index.js" 28 | } 29 | }, 30 | "scripts": { 31 | "build": "yarn p:build", 32 | "build:watch": "yarn p:build:watch", 33 | "brl": "yarn p:brl", 34 | "clean": "yarn p:clean", 35 | "lint": "yarn p:lint", 36 | "lint:fix": "yarn p:lint:fix", 37 | "test": "yarn p:test", 38 | "test:watch": "yarn p:test:watch", 39 | "typecheck": "yarn p:typecheck" 40 | }, 41 | "peerDependencies": { 42 | "@types/react": ">=17.0.0", 43 | "jotai": ">=2.0.0", 44 | "react": ">=17.0.0" 45 | }, 46 | "peerDependenciesMeta": { 47 | "@types/react": { 48 | "optional": true 49 | }, 50 | "react": { 51 | "optional": true 52 | } 53 | }, 54 | "keywords": [ 55 | "jotai" 56 | ], 57 | "publishConfig": { 58 | "access": "public" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/jotai-x/src/atomWithFn.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | 3 | import type { WritableAtom } from 'jotai/vanilla'; 4 | 5 | type WrapFn = T extends (...args: infer _A) => infer _R ? { __fn: T } : T; 6 | 7 | const wrapFn = (fnOrValue: T): WrapFn => 8 | (typeof fnOrValue === 'function' ? { __fn: fnOrValue } : fnOrValue) as any; 9 | 10 | type UnwrapFn = T extends { __fn: infer U } ? U : T; 11 | 12 | const unwrapFn = (wrappedFnOrValue: T): UnwrapFn => 13 | (wrappedFnOrValue && 14 | typeof wrappedFnOrValue === 'object' && 15 | '__fn' in wrappedFnOrValue 16 | ? wrappedFnOrValue.__fn 17 | : wrappedFnOrValue) as any; 18 | 19 | /** 20 | * Jotai atoms don't allow functions as values by default. This function is a 21 | * drop-in replacement for `atom` that wraps functions in an object while 22 | * leaving non-functions unchanged. The wrapper object should be completely 23 | * invisible to consumers of the atom. 24 | */ 25 | export const atomWithFn = (initialValue: T): WritableAtom => { 26 | const baseAtom = atom(wrapFn(initialValue)); 27 | 28 | return atom( 29 | (get) => unwrapFn(get(baseAtom)) as T, 30 | (_get, set, value) => set(baseAtom, wrapFn(value)) 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/jotai-x/src/createAtomProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { PropsWithChildren } from 'react'; 4 | import { createStore } from 'jotai/vanilla'; 5 | 6 | import { JotaiStore, SimpleWritableAtomRecord } from './createAtomStore'; 7 | import { useHydrateStore, useSyncStore } from './useHydrateStore'; 8 | 9 | const getFullyQualifiedScope = (storeName: string, scope: string) => { 10 | return `${storeName}:${scope}`; 11 | }; 12 | 13 | /** 14 | * Context mapping store name and scope to store. The 'provider' scope is used 15 | * to reference any provider belonging to the store, regardless of scope. 16 | */ 17 | const PROVIDER_SCOPE = 'provider'; 18 | const AtomStoreContext = React.createContext>( 19 | new Map() 20 | ); 21 | 22 | /** 23 | * Tries to find a store in each of the following places, in order: 24 | * 1. The store context, matching the store name and scope 25 | * 2. The store context, matching the store name and 'provider' scope 26 | * 3. Otherwise, return undefined 27 | */ 28 | export const useAtomStore = ( 29 | storeName: string, 30 | scope: string = PROVIDER_SCOPE, 31 | warnIfUndefined: boolean = true 32 | ): JotaiStore | undefined => { 33 | const storeContext = React.useContext(AtomStoreContext); 34 | const store = 35 | storeContext.get(getFullyQualifiedScope(storeName, scope)) ?? 36 | storeContext.get(getFullyQualifiedScope(storeName, PROVIDER_SCOPE)); 37 | 38 | if (!store && warnIfUndefined) { 39 | console.warn( 40 | `Tried to access jotai store '${storeName}' outside of a matching provider.` 41 | ); 42 | } 43 | 44 | return store; 45 | }; 46 | 47 | export type ProviderProps = Partial & 48 | PropsWithChildren<{ 49 | store?: JotaiStore; 50 | scope?: string; 51 | initialValues?: Partial; 52 | resetKey?: any; 53 | }>; 54 | 55 | export const HydrateAtoms = ({ 56 | initialValues, 57 | children, 58 | store, 59 | atoms, 60 | ...props 61 | }: Omit, 'scope'> & { 62 | atoms: SimpleWritableAtomRecord; 63 | }) => { 64 | useHydrateStore(atoms, { ...initialValues, ...props } as any, { 65 | store, 66 | }); 67 | useSyncStore(atoms, props as any, { 68 | store, 69 | }); 70 | 71 | return <>{children}; 72 | }; 73 | 74 | /** 75 | * Creates a generic provider for a jotai store. 76 | * - `initialValues`: Initial values for the store. 77 | * - `props`: Dynamic values for the store. 78 | */ 79 | export const createAtomProvider = ( 80 | storeScope: N, 81 | atoms: SimpleWritableAtomRecord, 82 | options: { effect?: React.FC } = {} 83 | ) => { 84 | const Effect = options.effect; 85 | 86 | // eslint-disable-next-line react/display-name 87 | return ({ store, scope, children, resetKey, ...props }: ProviderProps) => { 88 | const [storeState, setStoreState] = 89 | React.useState(createStore()); 90 | 91 | React.useEffect(() => { 92 | if (resetKey) { 93 | setStoreState(createStore()); 94 | } 95 | }, [resetKey]); 96 | 97 | const previousStoreContext = React.useContext(AtomStoreContext); 98 | 99 | const storeContext = React.useMemo(() => { 100 | const newStoreContext = new Map(previousStoreContext); 101 | 102 | if (scope) { 103 | // Make the store findable by its fully qualified scope 104 | newStoreContext.set( 105 | getFullyQualifiedScope(storeScope, scope), 106 | storeState 107 | ); 108 | } 109 | 110 | // Make the store findable by its store name alone 111 | newStoreContext.set( 112 | getFullyQualifiedScope(storeScope, PROVIDER_SCOPE), 113 | storeState 114 | ); 115 | 116 | return newStoreContext; 117 | }, [previousStoreContext, scope, storeState]); 118 | 119 | return ( 120 | 121 | 122 | {!!Effect && } 123 | 124 | {children} 125 | 126 | 127 | ); 128 | }; 129 | }; 130 | -------------------------------------------------------------------------------- /packages/jotai-x/src/createAtomStore.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-compiler/react-compiler */ 2 | import '@testing-library/jest-dom'; 3 | 4 | import React, { useCallback } from 'react'; 5 | import { act, queryByText, render, renderHook } from '@testing-library/react'; 6 | import { atom, PrimitiveAtom, useAtomValue } from 'jotai'; 7 | import { splitAtom } from 'jotai/utils'; 8 | 9 | import { 10 | createAtomStore, 11 | useAtomStoreSet, 12 | useAtomStoreState, 13 | useAtomStoreValue, 14 | useStoreAtomValue, 15 | } from './createAtomStore'; 16 | 17 | describe('createAtomStore', () => { 18 | describe('no unnecessary rerender', () => { 19 | type MyTestStoreValue = { 20 | num: number; 21 | arr: string[]; 22 | }; 23 | 24 | const INITIAL_NUM = 0; 25 | const INITIAL_ARR = ['alice', 'bob']; 26 | 27 | const initialTestStoreValue: MyTestStoreValue = { 28 | num: INITIAL_NUM, 29 | arr: INITIAL_ARR, 30 | }; 31 | 32 | const { 33 | myTestStoreStore, 34 | useMyTestStoreStore, 35 | MyTestStoreProvider, 36 | useMyTestStoreValue, 37 | } = createAtomStore(initialTestStoreValue, { 38 | name: 'myTestStore' as const, 39 | }); 40 | 41 | let numRenderCount = 0; 42 | const NumRenderer = () => { 43 | numRenderCount += 1; 44 | const num = useMyTestStoreStore().useNumValue(); 45 | return
{num}
; 46 | }; 47 | 48 | let arrRenderCount = 0; 49 | const ArrRenderer = () => { 50 | arrRenderCount += 1; 51 | const arr = useMyTestStoreStore().useArrValue(); 52 | return
{`[${arr.join(', ')}]`}
; 53 | }; 54 | 55 | let arrRendererWithShallowRenderCount = 0; 56 | const ArrRendererWithShallow = () => { 57 | arrRendererWithShallowRenderCount += 1; 58 | const equalityFn = useCallback((a: string[], b: string[]) => { 59 | if (a.length !== b.length) return false; 60 | for (let i = 0; i < a.length; i += 1) { 61 | if (a[i] !== b[i]) return false; 62 | } 63 | return true; 64 | }, []); 65 | const arr = useMyTestStoreStore().useArrValue(undefined, equalityFn); 66 | return
{`[${arr.join(', ')}]`}
; 67 | }; 68 | 69 | let arr0RenderCount = 0; 70 | const Arr0Renderer = () => { 71 | arr0RenderCount += 1; 72 | const select0 = useCallback((v: string[]) => v[0], []); 73 | const arr0 = useMyTestStoreStore().useArrValue(select0); 74 | return
{arr0}
; 75 | }; 76 | 77 | let arr1RenderCount = 0; 78 | const Arr1Renderer = () => { 79 | arr1RenderCount += 1; 80 | const select1 = useCallback((v: string[]) => v[1], []); 81 | const arr1 = useMyTestStoreStore().useArrValue(select1); 82 | return
{arr1}
; 83 | }; 84 | 85 | let arrNumRenderCount = 0; 86 | const ArrNumRenderer = () => { 87 | arrNumRenderCount += 1; 88 | const store = useMyTestStoreStore(); 89 | const num = store.useNumValue(); 90 | const selectArrNum = useCallback((v: string[]) => v[num], [num]); 91 | const arrNum = store.useArrValue(selectArrNum); 92 | return ( 93 |
94 |
arrNum: {arrNum}
95 |
96 | ); 97 | }; 98 | 99 | let arrNumRenderCountWithOneHook = 0; 100 | const ArrNumRendererWithOneHook = () => { 101 | arrNumRenderCountWithOneHook += 1; 102 | const num = useMyTestStoreValue('num'); 103 | const arrNum = useMyTestStoreValue( 104 | 'arr', 105 | { 106 | selector: (v) => v[num], 107 | }, 108 | [num] 109 | ); 110 | return ( 111 |
112 |
arrNumWithOneHook: {arrNum}
113 |
114 | ); 115 | }; 116 | 117 | let arrNumRenderWithDepsCount = 0; 118 | const ArrNumRendererWithDeps = () => { 119 | arrNumRenderWithDepsCount += 1; 120 | const store = useMyTestStoreStore(); 121 | const num = store.useNumValue(); 122 | const arrNum = store.useArrValue((v) => v[num], [num]); 123 | return ( 124 |
125 |
arrNumWithDeps: {arrNum}
126 |
127 | ); 128 | }; 129 | 130 | let arrNumRenderWithDepsAndAtomCount = 0; 131 | const ArrNumRendererWithDepsAndAtom = () => { 132 | arrNumRenderWithDepsAndAtomCount += 1; 133 | const store = useMyTestStoreStore(); 134 | const numAtom = myTestStoreStore.atom.num; 135 | const num = store.useAtomValue(numAtom); 136 | const arrAtom = myTestStoreStore.atom.arr; 137 | const arrNum = store.useAtomValue(arrAtom, (v) => v[num], [num]); 138 | return ( 139 |
140 |
arrNumWithDepsAndAtom: {arrNum}
141 |
142 | ); 143 | }; 144 | 145 | const BadSelectorRenderer = () => { 146 | const arr0 = useMyTestStoreStore().useArrValue((v) => v[0]); 147 | return
{arr0}
; 148 | }; 149 | 150 | const BadSelectorRenderer2 = () => { 151 | const arr0 = useMyTestStoreValue('arr', { selector: (v) => v[0] }); 152 | return
{arr0}
; 153 | }; 154 | 155 | const Buttons = () => { 156 | const store = useMyTestStoreStore(); 157 | return ( 158 |
159 | 165 | 171 | 177 | 186 |
187 | ); 188 | }; 189 | 190 | it('does not rerender when unrelated state changes', () => { 191 | const { getByText } = render( 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | ); 205 | 206 | // Why it's 2, not 1? Is React StrictMode causing this? 207 | expect(numRenderCount).toBe(2); 208 | expect(arrRenderCount).toBe(2); 209 | expect(arrRendererWithShallowRenderCount).toBe(2); 210 | expect(arr0RenderCount).toBe(2); 211 | expect(arr1RenderCount).toBe(2); 212 | expect(arrNumRenderCount).toBe(2); 213 | expect(arrNumRenderCountWithOneHook).toBe(2); 214 | expect(arrNumRenderWithDepsCount).toBe(2); 215 | expect(arrNumRenderWithDepsAndAtomCount).toBe(2); 216 | expect(getByText('arrNum: alice')).toBeInTheDocument(); 217 | expect(getByText('arrNumWithDeps: alice')).toBeInTheDocument(); 218 | 219 | act(() => getByText('increment').click()); 220 | expect(numRenderCount).toBe(3); 221 | expect(arrRenderCount).toBe(2); 222 | expect(arrRendererWithShallowRenderCount).toBe(2); 223 | expect(arr0RenderCount).toBe(2); 224 | expect(arr1RenderCount).toBe(2); 225 | expect(arrNumRenderCount).toBe(5); 226 | expect(arrNumRenderCountWithOneHook).toBe(5); 227 | expect(arrNumRenderWithDepsCount).toBe(5); 228 | expect(arrNumRenderWithDepsAndAtomCount).toBe(5); 229 | expect(getByText('arrNum: bob')).toBeInTheDocument(); 230 | expect(getByText('arrNumWithDeps: bob')).toBeInTheDocument(); 231 | 232 | act(() => getByText('add one name').click()); 233 | expect(numRenderCount).toBe(3); 234 | expect(arrRenderCount).toBe(3); 235 | expect(arrRendererWithShallowRenderCount).toBe(3); 236 | expect(arr0RenderCount).toBe(2); 237 | expect(arr1RenderCount).toBe(2); 238 | expect(arrNumRenderCount).toBe(5); 239 | expect(arrNumRenderCountWithOneHook).toBe(5); 240 | expect(arrNumRenderWithDepsCount).toBe(5); 241 | expect(arrNumRenderWithDepsAndAtomCount).toBe(5); 242 | expect(getByText('arrNum: bob')).toBeInTheDocument(); 243 | expect(getByText('arrNumWithDeps: bob')).toBeInTheDocument(); 244 | 245 | act(() => getByText('copy array').click()); 246 | expect(numRenderCount).toBe(3); 247 | expect(arrRenderCount).toBe(4); 248 | expect(arrRendererWithShallowRenderCount).toBe(3); 249 | expect(arr0RenderCount).toBe(2); 250 | expect(arr1RenderCount).toBe(2); 251 | expect(arrNumRenderCount).toBe(5); 252 | expect(arrNumRenderCountWithOneHook).toBe(5); 253 | expect(arrNumRenderWithDepsCount).toBe(5); 254 | expect(arrNumRenderWithDepsAndAtomCount).toBe(5); 255 | expect(getByText('arrNum: bob')).toBeInTheDocument(); 256 | expect(getByText('arrNumWithDeps: bob')).toBeInTheDocument(); 257 | 258 | act(() => getByText('modify arr0').click()); 259 | expect(numRenderCount).toBe(4); 260 | expect(arrRenderCount).toBe(5); 261 | expect(arrRendererWithShallowRenderCount).toBe(4); 262 | expect(arr0RenderCount).toBe(3); 263 | expect(arr1RenderCount).toBe(2); 264 | expect(arrNumRenderCount).toBe(8); 265 | expect(arrNumRenderCountWithOneHook).toBe(8); 266 | expect(arrNumRenderWithDepsCount).toBe(8); 267 | expect(arrNumRenderWithDepsAndAtomCount).toBe(8); 268 | expect(getByText('arrNum: ava')).toBeInTheDocument(); 269 | expect(getByText('arrNumWithDeps: ava')).toBeInTheDocument(); 270 | }); 271 | 272 | it('Throw error if user does not memoize selector', () => { 273 | expect(() => 274 | render( 275 | 276 | 277 | 278 | ) 279 | ).toThrow(); 280 | }); 281 | 282 | it('Throw error is user does memoize selector 2', () => { 283 | expect(() => 284 | render( 285 | 286 | 287 | 288 | ) 289 | ).toThrow(); 290 | }); 291 | }); 292 | 293 | describe('single provider', () => { 294 | type MyTestStoreValue = { 295 | name: string; 296 | age: number; 297 | becomeFriends: () => void; 298 | }; 299 | 300 | const INITIAL_NAME = 'John'; 301 | const INITIAL_AGE = 42; 302 | 303 | const initialTestStoreValue: MyTestStoreValue = { 304 | name: INITIAL_NAME, 305 | age: INITIAL_AGE, 306 | becomeFriends: () => {}, 307 | }; 308 | 309 | const { 310 | myTestStoreStore, 311 | useMyTestStoreStore, 312 | MyTestStoreProvider, 313 | useMyTestStoreSet, 314 | useMyTestStoreState, 315 | useMyTestStoreValue, 316 | } = createAtomStore(initialTestStoreValue, { 317 | name: 'myTestStore' as const, 318 | }); 319 | 320 | const ReadOnlyConsumer = () => { 321 | const name = useMyTestStoreStore().useNameValue(); 322 | const age = useMyTestStoreStore().useAgeValue(); 323 | 324 | return ( 325 |
326 | {name} 327 | {age} 328 |
329 | ); 330 | }; 331 | 332 | const ReadOnlyConsumerWithKeyParam = () => { 333 | const name = useMyTestStoreStore().useValue('name'); 334 | const age = useMyTestStoreStore().useValue('age'); 335 | 336 | return ( 337 |
338 | {name} 339 | {age} 340 |
341 | ); 342 | }; 343 | 344 | const WRITE_ONLY_CONSUMER_AGE = 99; 345 | 346 | const WriteOnlyConsumer = () => { 347 | const setAge = useMyTestStoreStore().useSetAge(); 348 | 349 | return ( 350 | 353 | ); 354 | }; 355 | 356 | const WriteOnlyConsumerWithKeyParam = () => { 357 | const setAge = useMyTestStoreStore().useSet('age'); 358 | 359 | return ( 360 | 363 | ); 364 | }; 365 | 366 | const SubscribeConsumer = ({ 367 | subName, 368 | subAge, 369 | }: { 370 | subName: (newName: string) => void; 371 | subAge: (newAge: number) => void; 372 | }) => { 373 | const store = useMyTestStoreStore(); 374 | 375 | React.useEffect(() => { 376 | const unsubscribeName = store.subscribeName(subName); 377 | const unsubscribeAge = store.subscribeAge(subAge); 378 | return () => { 379 | unsubscribeName(); 380 | unsubscribeAge(); 381 | }; 382 | }, [store, subName, subAge]); 383 | 384 | return null; 385 | }; 386 | 387 | const SubscribeConsumerWithKeyParam = ({ 388 | subName, 389 | subAge, 390 | }: { 391 | subName: (newName: string) => void; 392 | subAge: (newAge: number) => void; 393 | }) => { 394 | const store = useMyTestStoreStore(); 395 | 396 | React.useEffect(() => { 397 | const unsubscribeName = store.subscribe('name', subName); 398 | const unsubscribeAge = store.subscribe('age', subAge); 399 | return () => { 400 | unsubscribeName(); 401 | unsubscribeAge(); 402 | }; 403 | }, [store, subName, subAge]); 404 | 405 | return null; 406 | }; 407 | 408 | const MUTABLE_PROVIDER_INITIAL_AGE = 19; 409 | const MUTABLE_PROVIDER_NEW_AGE = 20; 410 | 411 | const MutableProvider = ({ children }: { children: React.ReactNode }) => { 412 | const [age, setAge] = React.useState(MUTABLE_PROVIDER_INITIAL_AGE); 413 | 414 | return ( 415 | <> 416 | {children} 417 | 418 | 424 | 425 | ); 426 | }; 427 | 428 | const BecomeFriendsProvider = ({ 429 | children, 430 | }: { 431 | children: React.ReactNode; 432 | }) => { 433 | const [becameFriends, setBecameFriends] = React.useState(false); 434 | 435 | return ( 436 | <> 437 | setBecameFriends(true)}> 438 | {children} 439 | 440 | 441 |
becameFriends: {becameFriends.toString()}
442 | 443 | ); 444 | }; 445 | 446 | const BecomeFriendsUseValue = () => { 447 | // Make sure both of these are actual functions, not wrapped functions 448 | const becomeFriends1 = useMyTestStoreStore().useBecomeFriendsValue(); 449 | const becomeFriends2 = useMyTestStoreStore().useAtomValue( 450 | myTestStoreStore.atom.becomeFriends 451 | ); 452 | 453 | return ( 454 | 463 | ); 464 | }; 465 | 466 | const BecomeFriendsUseValueWithKeyParam = () => { 467 | const becomeFriends1 = useMyTestStoreStore().useValue('becomeFriends'); 468 | const becomeFriends2 = useMyTestStoreStore().useAtomValue( 469 | myTestStoreStore.atom.becomeFriends 470 | ); 471 | 472 | return ( 473 | 482 | ); 483 | }; 484 | 485 | const BecomeFriendUseValueNoReactCompilerComplain = () => { 486 | // Just guarantee that the react compiler doesn't complain 487 | /* eslint-enable react-compiler/react-compiler */ 488 | const store = useMyTestStoreStore(); 489 | const becomeFriends0 = useMyTestStoreValue('becomeFriends'); 490 | const becomeFriends1 = useAtomStoreValue(store, 'becomeFriends'); 491 | const becomeFriends2 = useStoreAtomValue( 492 | store, 493 | myTestStoreStore.atom.becomeFriends 494 | ); 495 | 496 | return ( 497 | 507 | ); 508 | /* eslint-disable react-compiler/react-compiler */ 509 | }; 510 | 511 | const BecomeFriendsGet = () => { 512 | // Make sure both of these are actual functions, not wrapped functions 513 | const store = useMyTestStoreStore(); 514 | 515 | return ( 516 | 525 | ); 526 | }; 527 | 528 | const BecomeFriendsGetWithKeyParam = () => { 529 | const store = useMyTestStoreStore(); 530 | 531 | return ( 532 | 541 | ); 542 | }; 543 | 544 | const BecomeFriendsUseSet = () => { 545 | const setBecomeFriends = useMyTestStoreStore().useSetBecomeFriends(); 546 | const [becameFriends, setBecameFriends] = React.useState(false); 547 | 548 | return ( 549 | <> 550 | 556 | 557 |
useSetBecameFriends: {becameFriends.toString()}
558 | 559 | ); 560 | }; 561 | 562 | const BecomeFriendsUseSetWithKeyParam = () => { 563 | const setBecomeFriends = useMyTestStoreStore().useSet('becomeFriends'); 564 | const [becameFriends, setBecameFriends] = React.useState(false); 565 | 566 | return ( 567 | <> 568 | 574 | 575 |
useSetBecameFriends: {becameFriends.toString()}
576 | 577 | ); 578 | }; 579 | 580 | const BecomeFriendsUseSetNoReactCompilerComplain = () => { 581 | // Just guarantee that the react compiler doesn't complain 582 | /* eslint-enable react-compiler/react-compiler */ 583 | const store = useMyTestStoreStore(); 584 | const setName = useMyTestStoreSet('name'); 585 | const setBecomeFriends = useAtomStoreSet(store, 'becomeFriends'); 586 | const [becameFriends, setBecameFriends] = React.useState(false); 587 | 588 | return ( 589 | <> 590 | 599 | 600 |
useSetBecameFriends: {becameFriends.toString()}
601 | 602 | ); 603 | /* eslint-disable react-compiler/react-compiler */ 604 | }; 605 | 606 | const BecomeFriendsSet = () => { 607 | const store = useMyTestStoreStore(); 608 | const [becameFriends, setBecameFriends] = React.useState(false); 609 | return ( 610 | <> 611 | 617 | 618 |
setBecameFriends: {becameFriends.toString()}
619 | 620 | ); 621 | }; 622 | 623 | const BecomeFriendsSetWithKeyParam = () => { 624 | const store = useMyTestStoreStore(); 625 | const [becameFriends, setBecameFriends] = React.useState(false); 626 | return ( 627 | <> 628 | 636 | 637 |
setBecameFriends: {becameFriends.toString()}
638 | 639 | ); 640 | }; 641 | 642 | const BecomeFriendsUseState = () => { 643 | const [, setBecomeFriends] = 644 | useMyTestStoreStore().useBecomeFriendsState(); 645 | const [becameFriends, setBecameFriends] = React.useState(false); 646 | 647 | return ( 648 | <> 649 | 655 | 656 |
useBecameFriends: {becameFriends.toString()}
657 | 658 | ); 659 | }; 660 | 661 | const BecomeFriendsUseStateWithKeyParam = () => { 662 | const [, setBecomeFriends] = 663 | useMyTestStoreStore().useState('becomeFriends'); 664 | const [becameFriends, setBecameFriends] = React.useState(false); 665 | 666 | return ( 667 | <> 668 | 674 | 675 |
useBecameFriends: {becameFriends.toString()}
676 | 677 | ); 678 | }; 679 | 680 | const BecomeFriendsUseStateNoReactCompilerComplain = () => { 681 | // Just guarantee that the react compiler doesn't complain 682 | /* eslint-enable react-compiler/react-compiler */ 683 | const store = useMyTestStoreStore(); 684 | const [name, setName] = useMyTestStoreState('name'); 685 | const [, setBecomeFriends] = useAtomStoreState(store, 'becomeFriends'); 686 | const [becameFriends, setBecameFriends] = React.useState(false); 687 | 688 | return ( 689 | <> 690 | 699 | 700 |
useBecameFriends: {becameFriends.toString()}
701 | 702 | ); 703 | /* eslint-disable react-compiler/react-compiler */ 704 | }; 705 | 706 | beforeEach(() => { 707 | renderHook(() => useMyTestStoreStore().setName(INITIAL_NAME)); 708 | renderHook(() => useMyTestStoreStore().setAge(INITIAL_AGE)); 709 | }); 710 | 711 | it('passes default values from provider to consumer', () => { 712 | const { getByText } = render( 713 | 714 | 715 | 716 | ); 717 | 718 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 719 | expect(getByText(INITIAL_AGE)).toBeInTheDocument(); 720 | }); 721 | 722 | it('passes default values from provider to consumer with key param', () => { 723 | const { getByText } = render( 724 | 725 | 726 | 727 | ); 728 | 729 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 730 | expect(getByText(INITIAL_AGE)).toBeInTheDocument(); 731 | }); 732 | 733 | it('passes non-default values from provider to consumer', () => { 734 | const { getByText } = render( 735 | 736 | 737 | 738 | ); 739 | 740 | expect(getByText('Jane')).toBeInTheDocument(); 741 | expect(getByText('94')).toBeInTheDocument(); 742 | }); 743 | 744 | it('passes non-default values from provider to consumer with key param', () => { 745 | const { getByText } = render( 746 | 747 | 748 | 749 | ); 750 | 751 | expect(getByText('Jane')).toBeInTheDocument(); 752 | expect(getByText('94')).toBeInTheDocument(); 753 | }); 754 | 755 | it('propagates updates from provider to consumer', () => { 756 | const { getByText } = render( 757 | 758 | 759 | 760 | ); 761 | 762 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 763 | expect(getByText(MUTABLE_PROVIDER_INITIAL_AGE)).toBeInTheDocument(); 764 | 765 | act(() => getByText('providerSetAge').click()); 766 | 767 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 768 | expect(getByText(MUTABLE_PROVIDER_NEW_AGE)).toBeInTheDocument(); 769 | }); 770 | 771 | it('propagates updates from provider to consumer with key param', () => { 772 | const { getByText } = render( 773 | 774 | 775 | 776 | ); 777 | 778 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 779 | expect(getByText(MUTABLE_PROVIDER_INITIAL_AGE)).toBeInTheDocument(); 780 | 781 | act(() => getByText('providerSetAge').click()); 782 | 783 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 784 | expect(getByText(MUTABLE_PROVIDER_NEW_AGE)).toBeInTheDocument(); 785 | }); 786 | 787 | it('propagates updates between consumers', () => { 788 | const { getByText } = render( 789 | 790 | 791 | 792 | 793 | ); 794 | 795 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 796 | expect(getByText(INITIAL_AGE)).toBeInTheDocument(); 797 | 798 | act(() => getByText('consumerSetAge').click()); 799 | 800 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 801 | expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); 802 | }); 803 | 804 | it('propagates updates between consumers with key param', () => { 805 | const { getByText } = render( 806 | 807 | 808 | 809 | 810 | ); 811 | 812 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 813 | expect(getByText(INITIAL_AGE)).toBeInTheDocument(); 814 | 815 | act(() => getByText('consumerSetAge').click()); 816 | 817 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 818 | expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); 819 | }); 820 | 821 | it('prefers the most recent update', () => { 822 | const { getByText } = render( 823 | 824 | 825 | 826 | 827 | ); 828 | 829 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 830 | expect(getByText(MUTABLE_PROVIDER_INITIAL_AGE)).toBeInTheDocument(); 831 | 832 | act(() => getByText('consumerSetAge').click()); 833 | 834 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 835 | expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); 836 | 837 | act(() => getByText('providerSetAge').click()); 838 | 839 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 840 | expect(getByText(MUTABLE_PROVIDER_NEW_AGE)).toBeInTheDocument(); 841 | 842 | act(() => getByText('consumerSetAge').click()); 843 | 844 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 845 | expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); 846 | }); 847 | 848 | it('prefers the most recent update with key param', () => { 849 | const { getByText } = render( 850 | 851 | 852 | 853 | 854 | ); 855 | 856 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 857 | expect(getByText(MUTABLE_PROVIDER_INITIAL_AGE)).toBeInTheDocument(); 858 | 859 | act(() => getByText('consumerSetAge').click()); 860 | 861 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 862 | expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); 863 | 864 | act(() => getByText('providerSetAge').click()); 865 | 866 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 867 | expect(getByText(MUTABLE_PROVIDER_NEW_AGE)).toBeInTheDocument(); 868 | 869 | act(() => getByText('consumerSetAge').click()); 870 | 871 | expect(getByText(INITIAL_NAME)).toBeInTheDocument(); 872 | expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); 873 | }); 874 | 875 | it('can subscribe', () => { 876 | const subName = vi.fn(); 877 | const subAge = vi.fn(); 878 | const { getByText } = render( 879 | 880 | 881 | 882 | 883 | ); 884 | 885 | expect(subName).toHaveBeenCalledTimes(0); 886 | expect(subAge).toHaveBeenCalledTimes(0); 887 | 888 | act(() => getByText('consumerSetAge').click()); 889 | 890 | expect(subName).toHaveBeenCalledTimes(0); 891 | expect(subAge).toHaveBeenNthCalledWith(1, WRITE_ONLY_CONSUMER_AGE); 892 | 893 | act(() => getByText('providerSetAge').click()); 894 | 895 | expect(subName).toHaveBeenCalledTimes(0); 896 | expect(subAge).toHaveBeenNthCalledWith(2, MUTABLE_PROVIDER_NEW_AGE); 897 | 898 | act(() => getByText('consumerSetAge').click()); 899 | 900 | expect(subName).toHaveBeenCalledTimes(0); 901 | expect(subAge).toHaveBeenNthCalledWith(3, WRITE_ONLY_CONSUMER_AGE); 902 | }); 903 | 904 | it('can subscribe with key param', () => { 905 | const subName = vi.fn(); 906 | const subAge = vi.fn(); 907 | const { getByText } = render( 908 | 909 | 910 | 911 | 912 | ); 913 | 914 | expect(subName).toHaveBeenCalledTimes(0); 915 | expect(subAge).toHaveBeenCalledTimes(0); 916 | 917 | act(() => getByText('consumerSetAge').click()); 918 | 919 | expect(subName).toHaveBeenCalledTimes(0); 920 | expect(subAge).toHaveBeenNthCalledWith(1, WRITE_ONLY_CONSUMER_AGE); 921 | 922 | act(() => getByText('providerSetAge').click()); 923 | 924 | expect(subName).toHaveBeenCalledTimes(0); 925 | expect(subAge).toHaveBeenNthCalledWith(2, MUTABLE_PROVIDER_NEW_AGE); 926 | 927 | act(() => getByText('consumerSetAge').click()); 928 | 929 | expect(subName).toHaveBeenCalledTimes(0); 930 | expect(subAge).toHaveBeenNthCalledWith(3, WRITE_ONLY_CONSUMER_AGE); 931 | }); 932 | 933 | it('provides and useValue of functions', () => { 934 | const { getByText } = render( 935 | 936 | 937 | 938 | ); 939 | 940 | expect(getByText('becameFriends: false')).toBeInTheDocument(); 941 | act(() => getByText('Become Friends').click()); 942 | expect(getByText('becameFriends: true')).toBeInTheDocument(); 943 | }); 944 | 945 | it('provides and useValue of functions with key param', () => { 946 | const { getByText } = render( 947 | 948 | 949 | 950 | ); 951 | 952 | expect(getByText('becameFriends: false')).toBeInTheDocument(); 953 | act(() => getByText('Become Friends').click()); 954 | expect(getByText('becameFriends: true')).toBeInTheDocument(); 955 | }); 956 | 957 | it('provides and useValue of functions with no react compiler complain', () => { 958 | const { getByText } = render( 959 | 960 | 961 | 962 | ); 963 | 964 | expect(getByText('becameFriends: false')).toBeInTheDocument(); 965 | act(() => getByText('Become Friends').click()); 966 | expect(getByText('becameFriends: true')).toBeInTheDocument(); 967 | }); 968 | 969 | it('provides and get functions', () => { 970 | const { getByText } = render( 971 | 972 | 973 | 974 | ); 975 | 976 | expect(getByText('becameFriends: false')).toBeInTheDocument(); 977 | act(() => getByText('Become Friends').click()); 978 | expect(getByText('becameFriends: true')).toBeInTheDocument(); 979 | }); 980 | 981 | it('provides and get functions with key param', () => { 982 | const { getByText } = render( 983 | 984 | 985 | 986 | ); 987 | 988 | expect(getByText('becameFriends: false')).toBeInTheDocument(); 989 | act(() => getByText('Become Friends').click()); 990 | expect(getByText('becameFriends: true')).toBeInTheDocument(); 991 | }); 992 | 993 | it('useSet of functions', () => { 994 | const { getByText } = render( 995 | 996 | 997 | 998 | 999 | ); 1000 | 1001 | act(() => getByText('Change Callback').click()); 1002 | expect(getByText('useSetBecameFriends: false')).toBeInTheDocument(); 1003 | act(() => getByText('Become Friends').click()); 1004 | expect(getByText('useSetBecameFriends: true')).toBeInTheDocument(); 1005 | }); 1006 | 1007 | it('useSet of functions with key param', () => { 1008 | const { getByText } = render( 1009 | 1010 | 1011 | 1012 | 1013 | ); 1014 | 1015 | act(() => getByText('Change Callback').click()); 1016 | expect(getByText('useSetBecameFriends: false')).toBeInTheDocument(); 1017 | act(() => getByText('Become Friends').click()); 1018 | expect(getByText('useSetBecameFriends: true')).toBeInTheDocument(); 1019 | }); 1020 | 1021 | it('useSet of functions with no react compiler complain', () => { 1022 | const { getByText } = render( 1023 | 1024 | 1025 | 1026 | 1027 | ); 1028 | 1029 | act(() => getByText('Change Callback').click()); 1030 | expect(getByText('useSetBecameFriends: false')).toBeInTheDocument(); 1031 | act(() => getByText('Become Friends').click()); 1032 | expect(getByText('useSetBecameFriends: true')).toBeInTheDocument(); 1033 | }); 1034 | 1035 | it('set of functions', () => { 1036 | const { getByText } = render( 1037 | 1038 | 1039 | 1040 | 1041 | ); 1042 | 1043 | act(() => getByText('Change Callback').click()); 1044 | expect(getByText('setBecameFriends: false')).toBeInTheDocument(); 1045 | act(() => getByText('Become Friends').click()); 1046 | expect(getByText('setBecameFriends: true')).toBeInTheDocument(); 1047 | }); 1048 | 1049 | it('set of functions with key param', () => { 1050 | const { getByText } = render( 1051 | 1052 | 1053 | 1054 | 1055 | ); 1056 | 1057 | act(() => getByText('Change Callback').click()); 1058 | expect(getByText('setBecameFriends: false')).toBeInTheDocument(); 1059 | act(() => getByText('Become Friends').click()); 1060 | expect(getByText('setBecameFriends: true')).toBeInTheDocument(); 1061 | }); 1062 | 1063 | it('use state functions', () => { 1064 | const { getByText } = render( 1065 | 1066 | 1067 | 1068 | 1069 | ); 1070 | 1071 | act(() => getByText('Change Callback').click()); 1072 | expect(getByText('useBecameFriends: false')).toBeInTheDocument(); 1073 | act(() => getByText('Become Friends').click()); 1074 | expect(getByText('useBecameFriends: true')).toBeInTheDocument(); 1075 | }); 1076 | 1077 | it('use state functions with key param', () => { 1078 | const { getByText } = render( 1079 | 1080 | 1081 | 1082 | 1083 | ); 1084 | 1085 | act(() => getByText('Change Callback').click()); 1086 | expect(getByText('useBecameFriends: false')).toBeInTheDocument(); 1087 | act(() => getByText('Become Friends').click()); 1088 | expect(getByText('useBecameFriends: true')).toBeInTheDocument(); 1089 | }); 1090 | 1091 | it('use state functions with no react compiler complain', () => { 1092 | const { getByText } = render( 1093 | 1094 | 1095 | 1096 | 1097 | ); 1098 | 1099 | act(() => getByText('Change Callback John').click()); 1100 | expect(getByText('useBecameFriends: false')).toBeInTheDocument(); 1101 | act(() => getByText('Become Friends').click()); 1102 | expect(getByText('Change Callback new')).toBeInTheDocument(); 1103 | expect(getByText('useBecameFriends: true')).toBeInTheDocument(); 1104 | }); 1105 | 1106 | it('returns a stable store from useNameStore', () => { 1107 | const { result, rerender } = renderHook(useMyTestStoreStore); 1108 | const first = result.current; 1109 | rerender(); 1110 | const second = result.current; 1111 | expect(first === second).toBeTruthy(); 1112 | }); 1113 | }); 1114 | 1115 | describe('scoped providers', () => { 1116 | type MyScopedTestStoreValue = { age: number | null }; 1117 | 1118 | const initialScopedTestStoreValue: MyScopedTestStoreValue = { 1119 | age: null, 1120 | }; 1121 | 1122 | const { useMyScopedTestStoreStore, MyScopedTestStoreProvider } = 1123 | createAtomStore(initialScopedTestStoreValue, { 1124 | name: 'myScopedTestStore' as const, 1125 | }); 1126 | 1127 | const ReadOnlyConsumer = ({ scope }: { scope: string }) => { 1128 | const age = useMyScopedTestStoreStore({ scope }).useAgeValue(); 1129 | 1130 | return ( 1131 |
1132 | {JSON.stringify(age)} 1133 |
1134 | ); 1135 | }; 1136 | 1137 | const ReadOnlyConsumerWithScopeShorthand = ({ 1138 | scope, 1139 | }: { 1140 | scope: string; 1141 | }) => { 1142 | const age = useMyScopedTestStoreStore(scope).useAgeValue(); 1143 | 1144 | return ( 1145 |
1146 | {JSON.stringify(age)} 1147 |
1148 | ); 1149 | }; 1150 | 1151 | it('returns value of first ancestor when scope matches no provider', () => { 1152 | const { getByText } = render( 1153 | 1154 | 1155 | 1156 | 1157 | 1158 | ); 1159 | 1160 | expect(getByText('2')).toBeInTheDocument(); 1161 | }); 1162 | 1163 | it('returns value of first matching ancestor provider', () => { 1164 | const { getByText } = render( 1165 | 1166 | 1167 | 1168 | 1169 | 1170 | 1171 | 1172 | 1173 | 1174 | 1175 | 1176 | 1177 | 1178 | ); 1179 | 1180 | expect(getByText('4')).toBeInTheDocument(); 1181 | }); 1182 | 1183 | it('allows shorthand to specify scope', () => { 1184 | const { getByText } = render( 1185 | 1186 | 1187 | 1188 | 1189 | 1190 | 1191 | 1192 | 1193 | 1194 | 1195 | 1196 | 1197 | 1198 | ); 1199 | 1200 | expect(getByText('4')).toBeInTheDocument(); 1201 | }); 1202 | }); 1203 | 1204 | describe('multiple unrelated stores', () => { 1205 | type MyFirstTestStoreValue = { name: string }; 1206 | type MySecondTestStoreValue = { age: number }; 1207 | 1208 | const initialFirstTestStoreValue: MyFirstTestStoreValue = { 1209 | name: 'My name', 1210 | }; 1211 | 1212 | const initialSecondTestStoreValue: MySecondTestStoreValue = { 1213 | age: 72, 1214 | }; 1215 | 1216 | const { useMyFirstTestStoreStore, MyFirstTestStoreProvider } = 1217 | createAtomStore(initialFirstTestStoreValue, { 1218 | name: 'myFirstTestStore' as const, 1219 | }); 1220 | 1221 | const { useMySecondTestStoreStore, MySecondTestStoreProvider } = 1222 | createAtomStore(initialSecondTestStoreValue, { 1223 | name: 'mySecondTestStore' as const, 1224 | }); 1225 | 1226 | const FirstReadOnlyConsumer = () => { 1227 | const name = useMyFirstTestStoreStore().useNameValue(); 1228 | 1229 | return ( 1230 |
1231 | {name} 1232 |
1233 | ); 1234 | }; 1235 | 1236 | const SecondReadOnlyConsumer = () => { 1237 | const age = useMySecondTestStoreStore().useAgeValue(); 1238 | 1239 | return ( 1240 |
1241 | {age} 1242 |
1243 | ); 1244 | }; 1245 | 1246 | it('returns the value for the correct store', () => { 1247 | const { getByText } = render( 1248 | 1249 | 1250 | 1251 | 1252 | 1253 | 1254 | ); 1255 | 1256 | expect(getByText('Jane')).toBeInTheDocument(); 1257 | expect(getByText('98')).toBeInTheDocument(); 1258 | }); 1259 | }); 1260 | 1261 | describe('extended stores', () => { 1262 | type User = { 1263 | name: string; 1264 | age: number; 1265 | }; 1266 | 1267 | const initialUser: User = { 1268 | name: 'Jane', 1269 | age: 98, 1270 | }; 1271 | 1272 | const { userStore, useUserStore, useUserValue, UserProvider } = 1273 | createAtomStore(initialUser, { 1274 | name: 'user' as const, 1275 | extend: ({ name, age }) => ({ 1276 | bio: atom((get) => `${get(name)} is ${get(age)} years old`), 1277 | }), 1278 | }); 1279 | 1280 | const ReadOnlyConsumer = () => { 1281 | const bio = useUserValue('bio'); 1282 | 1283 | return
{bio}
; 1284 | }; 1285 | 1286 | it('includes extended atom in store object', () => { 1287 | const { result } = renderHook(() => useAtomValue(userStore.atom.bio)); 1288 | expect(result.current).toBe('Jane is 98 years old'); 1289 | }); 1290 | 1291 | it('includes extended atom in get hooks', () => { 1292 | const { result } = renderHook(() => useUserStore().useBioValue()); 1293 | expect(result.current).toBe('Jane is 98 years old'); 1294 | }); 1295 | 1296 | it('does not include read-only extended atom in set hooks', () => { 1297 | const { result } = renderHook(() => 1298 | Object.keys(useUserStore()).map((key) => { 1299 | const match = key.match(/^useSet(\w+)$/); 1300 | return match ? match[1].toLowerCase() : null; 1301 | }) 1302 | ); 1303 | expect(result.current).not.toContain('bio'); 1304 | }); 1305 | 1306 | it('does not include read-only extended atom in use hooks', () => { 1307 | const { result } = renderHook(() => 1308 | Object.keys(useUserStore()).map((key) => { 1309 | const match = key.match(/^use(\w+)State$/); 1310 | return match ? match[1].toLowerCase() : null; 1311 | }) 1312 | ); 1313 | expect(result.current).not.toContain('bio'); 1314 | }); 1315 | 1316 | it('computes extended atom based on current state', () => { 1317 | const { getByText } = render( 1318 | 1319 | 1320 | 1321 | ); 1322 | 1323 | expect(getByText('John is 42 years old')).toBeInTheDocument(); 1324 | }); 1325 | }); 1326 | 1327 | describe('passing atoms as part of initial state', () => { 1328 | type CustomAtom = PrimitiveAtom & { 1329 | isCustomAtom: true; 1330 | }; 1331 | 1332 | const createCustomAtom = (value: T): CustomAtom => ({ 1333 | ...atom(value), 1334 | isCustomAtom: true, 1335 | }); 1336 | 1337 | const { customStore, useCustomStore, CustomProvider } = createAtomStore( 1338 | { 1339 | x: createCustomAtom(1), 1340 | }, 1341 | { 1342 | name: 'custom' as const, 1343 | } 1344 | ); 1345 | 1346 | it('uses passed atom', () => { 1347 | const myAtom = customStore.atom.x as CustomAtom; 1348 | expect(myAtom.isCustomAtom).toBe(true); 1349 | }); 1350 | 1351 | it('accepts initial values', () => { 1352 | const { result } = renderHook(() => useCustomStore().useXValue(), { 1353 | wrapper: ({ children }) => ( 1354 | {children} 1355 | ), 1356 | }); 1357 | 1358 | expect(result.current).toBe(2); 1359 | }); 1360 | }); 1361 | 1362 | describe('arbitrary atom accessors', () => { 1363 | type User = { 1364 | name: string; 1365 | }; 1366 | 1367 | const initialUser: User = { 1368 | name: 'Jane', 1369 | }; 1370 | 1371 | const { userStore, useUserStore, UserProvider } = createAtomStore( 1372 | initialUser, 1373 | { 1374 | name: 'user' as const, 1375 | } 1376 | ); 1377 | 1378 | const derivedAtom = atom((get) => `My name is ${get(userStore.atom.name)}`); 1379 | 1380 | const DerivedAtomConsumer = () => { 1381 | const message = useUserStore().useAtomValue(derivedAtom); 1382 | 1383 | return
{message}
; 1384 | }; 1385 | 1386 | it('accesses arbitrary atom within store', () => { 1387 | const { getByText } = render( 1388 | 1389 | 1390 | 1391 | ); 1392 | 1393 | expect(getByText('My name is John')).toBeInTheDocument(); 1394 | }); 1395 | }); 1396 | 1397 | describe('splitAtoms using todoStore.atom.items', () => { 1398 | const initialState = { 1399 | items: [] as { 1400 | task: string; 1401 | done: boolean; 1402 | }[], 1403 | }; 1404 | 1405 | const { todoStore, useTodoStore, TodoProvider } = createAtomStore( 1406 | initialState, 1407 | { 1408 | name: 'todo' as const, 1409 | } 1410 | ); 1411 | 1412 | const todoAtomsAtom = splitAtom(todoStore.atom.items); 1413 | 1414 | type TodoType = (typeof initialState)['items'][number]; 1415 | 1416 | const TodoItem = ({ 1417 | todoAtom, 1418 | remove, 1419 | }: { 1420 | todoAtom: PrimitiveAtom; 1421 | remove: () => void; 1422 | }) => { 1423 | const [todo, setTodo] = useTodoStore().useAtomState(todoAtom); 1424 | 1425 | return ( 1426 |
1427 | 1428 | { 1432 | setTodo((oldValue) => ({ ...oldValue, done: !oldValue.done })); 1433 | }} 1434 | /> 1435 | {/* eslint-disable-next-line react/button-has-type */} 1436 | 1437 |
1438 | ); 1439 | }; 1440 | 1441 | const TodoList = () => { 1442 | const [todoAtoms, dispatch] = useTodoStore().useAtomState(todoAtomsAtom); 1443 | return ( 1444 |
    1445 | {todoAtoms.map((todoAtom) => ( 1446 | dispatch({ type: 'remove', atom: todoAtom })} 1450 | /> 1451 | ))} 1452 |
1453 | ); 1454 | }; 1455 | 1456 | it('should work', () => { 1457 | const { getByText, container } = render( 1458 | 1472 | 1473 | 1474 | ); 1475 | 1476 | expect(getByText('help the town')).toBeInTheDocument(); 1477 | expect(getByText('feed the dragon')).toBeInTheDocument(); 1478 | 1479 | act(() => getByText('remove help the town').click()); 1480 | 1481 | expect(queryByText(container, 'help the town')).not.toBeInTheDocument(); 1482 | expect(getByText('feed the dragon')).toBeInTheDocument(); 1483 | }); 1484 | }); 1485 | 1486 | describe('splitAtoms using extend', () => { 1487 | const initialState = { 1488 | items: [] as { 1489 | task: string; 1490 | done: boolean; 1491 | }[], 1492 | }; 1493 | 1494 | const { useTodoStore, TodoProvider } = createAtomStore(initialState, { 1495 | name: 'todo' as const, 1496 | extend: ({ items }) => ({ 1497 | itemAtoms: splitAtom(items), 1498 | }), 1499 | }); 1500 | 1501 | type TodoType = (typeof initialState)['items'][number]; 1502 | 1503 | const TodoItem = ({ 1504 | todoAtom, 1505 | remove, 1506 | }: { 1507 | todoAtom: PrimitiveAtom; 1508 | remove: () => void; 1509 | }) => { 1510 | const [todo, setTodo] = useTodoStore().useAtomState(todoAtom); 1511 | 1512 | return ( 1513 |
1514 | 1515 | { 1519 | setTodo((oldValue) => ({ ...oldValue, done: !oldValue.done })); 1520 | }} 1521 | /> 1522 | {/* eslint-disable-next-line react/button-has-type */} 1523 | 1524 |
1525 | ); 1526 | }; 1527 | 1528 | const TodoList = () => { 1529 | const [todoAtoms, dispatch] = useTodoStore().useItemAtomsState(); 1530 | 1531 | return ( 1532 |
    1533 | {todoAtoms.map((todoAtom) => ( 1534 | dispatch({ type: 'remove', atom: todoAtom })} 1538 | /> 1539 | ))} 1540 |
1541 | ); 1542 | }; 1543 | 1544 | it('should work', () => { 1545 | const { getByText, container } = render( 1546 | 1560 | 1561 | 1562 | ); 1563 | 1564 | expect(getByText('help the town')).toBeInTheDocument(); 1565 | expect(getByText('feed the dragon')).toBeInTheDocument(); 1566 | 1567 | act(() => getByText('remove help the town').click()); 1568 | 1569 | expect(queryByText(container, 'help the town')).not.toBeInTheDocument(); 1570 | expect(getByText('feed the dragon')).toBeInTheDocument(); 1571 | }); 1572 | }); 1573 | }); 1574 | -------------------------------------------------------------------------------- /packages/jotai-x/src/createAtomStore.ts: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { getDefaultStore, useAtom, useAtomValue, useSetAtom } from 'jotai'; 3 | import { selectAtom, useHydrateAtoms } from 'jotai/utils'; 4 | 5 | import { atomWithFn } from './atomWithFn'; 6 | import { createAtomProvider, useAtomStore } from './createAtomProvider'; 7 | 8 | import type { ProviderProps } from './createAtomProvider'; 9 | import type { Atom, createStore, WritableAtom } from 'jotai/vanilla'; 10 | 11 | export type JotaiStore = ReturnType; 12 | 13 | export type UseAtomOptions = { 14 | scope?: string; 15 | store?: JotaiStore; 16 | delay?: number; 17 | warnIfNoStore?: boolean; 18 | }; 19 | 20 | type UseAtomOptionsOrScope = UseAtomOptions | string; 21 | 22 | type UseValueRecord = { 23 | [K in keyof O]: O[K] extends Atom ? () => V : never; 24 | }; 25 | 26 | type GetRecord = UseValueRecord; 27 | 28 | type UseSetRecord = { 29 | [K in keyof O]: O[K] extends WritableAtom 30 | ? () => (...args: A) => R 31 | : never; 32 | }; 33 | 34 | type SetRecord = { 35 | [K in keyof O]: O[K] extends WritableAtom 36 | ? (...args: A) => R 37 | : never; 38 | }; 39 | 40 | type UseStateRecord = { 41 | [K in keyof O]: O[K] extends WritableAtom 42 | ? () => [V, (...args: A) => R] 43 | : never; 44 | }; 45 | 46 | type SubscribeRecord = { 47 | [K in keyof O]: O[K] extends Atom 48 | ? (callback: (newValue: V) => void) => () => void 49 | : never; 50 | }; 51 | 52 | type StoreAtomsWithoutExtend = { 53 | [K in keyof T]: T[K] extends Atom ? T[K] : SimpleWritableAtom; 54 | }; 55 | 56 | type ValueTypesForAtoms = { 57 | [K in keyof T]: T[K] extends Atom ? V : never; 58 | }; 59 | 60 | type StoreInitialValues = ValueTypesForAtoms>; 61 | 62 | type StoreAtoms = StoreAtomsWithoutExtend & E; 63 | 64 | type FilterWritableAtoms = { 65 | [K in keyof T]-?: T[K] extends WritableAtom ? T[K] : never; 66 | }; 67 | 68 | type WritableStoreAtoms = FilterWritableAtoms>; 69 | 70 | export type SimpleWritableAtom = WritableAtom; 71 | 72 | export type SimpleWritableAtomRecord = { 73 | [K in keyof T]: SimpleWritableAtom; 74 | }; 75 | 76 | export type AtomRecord = { 77 | [K in keyof O]: Atom; 78 | }; 79 | 80 | type UseNameStore = `use${Capitalize}Store`; 81 | type NameStore = N extends '' ? 'store' : `${N}Store`; 82 | type NameProvider = `${Capitalize}Provider`; 83 | 84 | type UseKeyValue = `use${Capitalize}Value`; 85 | type GetKey = `get${Capitalize}`; 86 | type UseSetKey = `useSet${Capitalize}`; 87 | type SetKey = `set${Capitalize}`; 88 | type UseKeyState = `use${Capitalize}State`; 89 | type SubscribeKey = `subscribe${Capitalize}`; 90 | 91 | export type UseHydrateAtoms = ( 92 | initialValues: Partial>, 93 | options?: Parameters[1] 94 | ) => void; 95 | export type UseSyncAtoms = ( 96 | values: Partial>, 97 | options?: { 98 | store?: JotaiStore; 99 | } 100 | ) => void; 101 | 102 | export type StoreApi< 103 | T extends object, 104 | E extends AtomRecord, 105 | N extends string = '', 106 | > = { 107 | atom: StoreAtoms; 108 | name: N; 109 | }; 110 | 111 | type GetAtomFn = ( 112 | atom: Atom, 113 | store?: JotaiStore, 114 | options?: UseAtomOptionsOrScope 115 | ) => V; 116 | 117 | type UseAtomValueFn = ( 118 | atom: Atom, 119 | store?: JotaiStore, 120 | options?: UseAtomOptionsOrScope, 121 | selector?: (v: V, prevSelectorOutput?: S) => S, 122 | equalityFnOrDeps?: 123 | | ((prevSelectorOutput: S, selectorOutput: S) => boolean) 124 | | unknown[], 125 | deps?: unknown[] 126 | ) => S; 127 | 128 | type SetAtomFn = ( 129 | atom: WritableAtom, 130 | store?: JotaiStore, 131 | options?: UseAtomOptionsOrScope 132 | ) => (...args: A) => R; 133 | 134 | type UseSetAtomFn = SetAtomFn; 135 | 136 | type UseAtomFn = ( 137 | atom: WritableAtom, 138 | store?: JotaiStore, 139 | options?: UseAtomOptionsOrScope 140 | ) => [V, (...args: A) => R]; 141 | 142 | type SubscribeAtomFn = ( 143 | atom: Atom, 144 | store?: JotaiStore, 145 | options?: UseAtomOptionsOrScope 146 | ) => (callback: (newValue: V) => void) => () => void; 147 | 148 | type UseValueOptions = { 149 | selector?: (v: V, prevSelectorOutput?: S) => S; 150 | equalityFn?: (prev: S, next: S) => boolean; 151 | } & UseAtomOptions; 152 | 153 | // store.useValue() 154 | export type UseKeyValueApis = { 155 | [K in keyof O as UseKeyValue]: { 156 | (): O[K] extends Atom ? V : never; 157 | ( 158 | selector: O[K] extends Atom 159 | ? (v: V, prevSelectorOutput?: S) => S 160 | : never, 161 | deps?: unknown[] 162 | ): S; 163 | ( 164 | selector: 165 | | (O[K] extends Atom 166 | ? (v: V, prevSelectorOutput?: S) => S 167 | : never) 168 | | undefined, 169 | equalityFn: (prevSelectorOutput: S, selectorOutput: S) => boolean, 170 | deps?: unknown[] 171 | ): S; 172 | }; 173 | }; 174 | 175 | // store.get() 176 | export type GetKeyApis = { 177 | [K in keyof O as GetKey]: O[K] extends Atom 178 | ? () => V 179 | : never; 180 | }; 181 | 182 | // store.useSet() 183 | export type UseSetKeyApis = { 184 | [K in keyof O as UseSetKey]: O[K] extends WritableAtom< 185 | infer _V, 186 | infer A, 187 | infer R 188 | > 189 | ? () => (...args: A) => R 190 | : never; 191 | }; 192 | 193 | // store.set(...args) 194 | export type SetKeyApis = { 195 | [K in keyof O as SetKey]: O[K] extends WritableAtom< 196 | infer _V, 197 | infer A, 198 | infer R 199 | > 200 | ? (...args: A) => R 201 | : never; 202 | }; 203 | 204 | // store.useState() 205 | export type UseKeyStateApis = { 206 | [K in keyof O as UseKeyState]: O[K] extends WritableAtom< 207 | infer V, 208 | infer A, 209 | infer R 210 | > 211 | ? () => [V, (...args: A) => R] 212 | : never; 213 | }; 214 | 215 | // store.subscribe(callback) 216 | export type SubscribeKeyApis = { 217 | [K in keyof O as SubscribeKey]: O[K] extends Atom 218 | ? (callback: (newValue: V) => void) => () => void 219 | : never; 220 | }; 221 | 222 | // store.useValue('key') 223 | export type UseParamKeyValueApi = { 224 | // abc 225 | (key: K): O[K] extends Atom ? V : never; 226 | ( 227 | key: K, 228 | selector: O[K] extends Atom 229 | ? (v: V, prevSelectorOutput?: S) => S 230 | : never, 231 | deps?: unknown[] 232 | ): S; 233 | ( 234 | key: K, 235 | selector: 236 | | (O[K] extends Atom 237 | ? (v: V, prevSelectorOutput?: S) => S 238 | : never) 239 | | undefined, 240 | equalityFn: (prevSelectorOutput: S, selectorOutput: S) => boolean, 241 | deps?: unknown[] 242 | ): S; 243 | }; 244 | 245 | // store.get('key') 246 | export type GetParamKeyApi = ( 247 | key: K 248 | ) => O[K] extends Atom ? V : never; 249 | 250 | // store.useSet('key') 251 | export type UseSetParamKeyApi = ( 252 | key: K 253 | ) => O[K] extends WritableAtom 254 | ? (...args: A) => R 255 | : never; 256 | // store.set('key', ...args) 257 | export type SetParamKeyApi = ( 258 | key: K, 259 | ...args: A 260 | ) => O[K] extends WritableAtom ? R : never; 261 | 262 | // store.useState('key') 263 | export type UseParamKeyStateApi = ( 264 | key: K 265 | ) => O[K] extends WritableAtom 266 | ? [V, (...args: A) => R] 267 | : never; 268 | 269 | // store.subscribe('key', callback) 270 | export type SubscribeParamKeyApi = ( 271 | key: K, 272 | callback: (newValue: V) => void 273 | ) => O[K] extends Atom ? () => void : never; 274 | 275 | export type UseAtomParamValueApi = { 276 | (atom: Atom): V; 277 | ( 278 | atom: Atom, 279 | selector: (v: V, prevSelectorOutput?: S) => S, 280 | deps?: unknown[] 281 | ): S; 282 | ( 283 | atom: Atom, 284 | selector: ((v: V, prevSelectorOutput?: S) => S) | undefined, 285 | equalityFn: (prevSelectorOutput: S, selectorOutput: S) => boolean, 286 | deps?: unknown[] 287 | ): S; 288 | }; 289 | export type GetAtomParamApi = (atom: Atom) => V; 290 | export type UseSetAtomParamApi = ( 291 | atom: WritableAtom 292 | ) => (...args: A) => R; 293 | export type SetAtomParamApi = ( 294 | atom: WritableAtom 295 | ) => (...args: A) => R; 296 | export type UseAtomParamStateApi = ( 297 | atom: WritableAtom 298 | ) => [V, (...args: A) => R]; 299 | export type SubscribeAtomParamApi = ( 300 | atom: Atom 301 | ) => (callback: (newValue: V) => void) => () => void; 302 | 303 | export type ReturnOfUseStoreApi = UseKeyValueApis> & 304 | GetKeyApis> & 305 | UseSetKeyApis> & 306 | SetKeyApis> & 307 | UseKeyStateApis> & 308 | SubscribeKeyApis> & { 309 | /** 310 | * When providing `selector`, the atom value will be transformed using the selector function. 311 | * The selector and equalityFn MUST be memoized. 312 | * 313 | * @see https://jotai.org/docs/utilities/select#selectatom 314 | * 315 | * @example 316 | * const store = useStore() 317 | * // only rerenders when the first element of the array changes 318 | * const arrayFirst = store.useValue('array', array => array[0], []) 319 | * // only rerenders when the first element of the array changes, but returns the whole array 320 | * const array = store.useValue('array', undefined, (prev, next) => prev[0] === next[0], []) 321 | * // without dependency array, then you need to memoize the selector and equalityFn yourself 322 | * const cb = useCallback((array) => array[n], [n]) 323 | * const arrayNth = store.useValue('array', cb) 324 | * 325 | * @param key The key of the atom 326 | * @param selector A function that takes the atom value and returns the value to be used. Defaults to identity function that returns the atom value. 327 | * @param equalityFnOrDeps Dependency array or a function that compares the previous selector output and the new selector output. Defaults to comparing outputs of the selector function. 328 | * @param deps Dependency array for the selector and equalityFn 329 | */ 330 | useValue: UseParamKeyValueApi>; 331 | get: GetParamKeyApi>; 332 | useSet: UseSetParamKeyApi>; 333 | set: SetParamKeyApi>; 334 | useState: UseParamKeyStateApi>; 335 | subscribe: SubscribeParamKeyApi>; 336 | /** 337 | * When providing `selector`, the atom value will be transformed using the selector function. 338 | * The selector and equalityFn MUST be memoized. 339 | * 340 | * @see https://jotai.org/docs/utilities/select#selectatom 341 | * 342 | * @example 343 | * const store = useStore() 344 | * // only rerenders when the first element of the array changes 345 | * const arrayFirst = store.useAtomValue(arrayAtom, array => array[0]) 346 | * // only rerenders when the first element of the array changes, but returns the whole array 347 | * const array = store.useAtomValue(arrayAtom, undefined, (prev, next) => prev[0] === next[0]) 348 | * // without dependency array, then you need to memoize the selector and equalityFn yourself 349 | * const cb = useCallback((array) => array[n], [n]) 350 | * const arrayNth = store.useAtomValue(arrayAtom, cb) 351 | * 352 | * @param atom The atom to use 353 | * @param selector A function that takes the atom value and returns the value to be used. Defaults to identity function that returns the atom value. 354 | * @param equalityFn Dependency array or a function that compares the previous selector output and the new selector output. Defaults to comparing outputs of the selector function. 355 | * @param deps Dependency array for the selector and equalityFn 356 | */ 357 | useAtomValue: UseAtomParamValueApi; 358 | getAtom: GetAtomParamApi; 359 | useSetAtom: UseSetAtomParamApi; 360 | setAtom: SetAtomParamApi; 361 | useAtomState: UseAtomParamStateApi; 362 | subscribeAtom: SubscribeAtomParamApi; 363 | store: JotaiStore | undefined; 364 | }; 365 | 366 | type UseKeyStateUtil = >( 367 | key: K, 368 | options?: UseAtomOptionsOrScope 369 | ) => StoreAtoms[K] extends WritableAtom 370 | ? [V, (...args: A) => R] 371 | : never; 372 | 373 | type UseKeyValueUtil = < 374 | K extends keyof StoreAtoms, 375 | S = StoreAtoms[K] extends Atom ? V : never, 376 | >( 377 | key: K, 378 | options?: UseValueOptions< 379 | StoreAtoms[K] extends Atom ? V : never, 380 | S 381 | >, 382 | deps?: unknown[] 383 | ) => S; 384 | 385 | type UseKeySetUtil = >( 386 | key: K, 387 | options?: UseAtomOptionsOrScope 388 | ) => StoreAtoms[K] extends WritableAtom 389 | ? (...args: A) => R 390 | : never; 391 | 392 | export type AtomStoreApi< 393 | T extends object, 394 | E extends AtomRecord, 395 | N extends string = '', 396 | > = { 397 | name: N; 398 | } & { 399 | [key in keyof Record, object>]: React.FC< 400 | ProviderProps> 401 | >; 402 | } & { 403 | [key in keyof Record, object>]: StoreApi; 404 | } & { 405 | [key in keyof Record, object>]: UseStoreApi; 406 | } & { 407 | [key in keyof Record<`use${Capitalize}State`, object>]: UseKeyStateUtil< 408 | T, 409 | E 410 | >; 411 | } & { 412 | [key in keyof Record<`use${Capitalize}Value`, object>]: UseKeyValueUtil< 413 | T, 414 | E 415 | >; 416 | } & { 417 | [key in keyof Record<`use${Capitalize}Set`, object>]: UseKeySetUtil; 418 | }; 419 | 420 | export type UseStoreApi = ( 421 | options?: UseAtomOptionsOrScope 422 | ) => ReturnOfUseStoreApi; 423 | 424 | const capitalizeFirstLetter = (str = '') => 425 | str.length > 0 ? str[0].toUpperCase() + str.slice(1) : ''; 426 | const getProviderIndex = (name = '') => 427 | `${capitalizeFirstLetter(name)}Provider`; 428 | const getStoreIndex = (name = '') => 429 | name.length > 0 ? `${name}Store` : 'store'; 430 | const getUseStoreIndex = (name = '') => 431 | `use${capitalizeFirstLetter(name)}Store`; 432 | 433 | const getUseValueIndex = (key = '') => `use${capitalizeFirstLetter(key)}Value`; 434 | const getGetIndex = (key = '') => `get${capitalizeFirstLetter(key)}`; 435 | const getUseSetIndex = (key = '') => `useSet${capitalizeFirstLetter(key)}`; 436 | const getSetIndex = (key = '') => `set${capitalizeFirstLetter(key)}`; 437 | const getUseStateIndex = (key = '') => `use${capitalizeFirstLetter(key)}State`; 438 | const getSubscribeIndex = (key = '') => 439 | `subscribe${capitalizeFirstLetter(key)}`; 440 | 441 | const isAtom = (possibleAtom: unknown): boolean => 442 | !!possibleAtom && 443 | typeof possibleAtom === 'object' && 444 | 'read' in possibleAtom && 445 | typeof possibleAtom.read === 'function'; 446 | 447 | const withStoreAndOptions = ( 448 | fnRecord: T, 449 | getIndex: (name?: string) => string, 450 | store: JotaiStore | undefined, 451 | options: UseAtomOptions 452 | ): any => 453 | Object.fromEntries( 454 | Object.entries(fnRecord).map(([key, fn]) => [ 455 | getIndex(key), 456 | (...args: any[]) => (fn as any)(store, options, ...args), 457 | ]) 458 | ); 459 | 460 | const withKeyAndStoreAndOptions = 461 | ( 462 | fnRecord: T, 463 | store: JotaiStore | undefined, 464 | options: UseAtomOptions 465 | ): any => 466 | (key: keyof T, ...args: any[]) => 467 | (fnRecord[key] as any)(store, options, ...args); 468 | 469 | const convertScopeShorthand = ( 470 | optionsOrScope: UseAtomOptionsOrScope = {} 471 | ): UseAtomOptions => 472 | typeof optionsOrScope === 'string' 473 | ? { scope: optionsOrScope } 474 | : optionsOrScope; 475 | 476 | const useConvertScopeShorthand: typeof convertScopeShorthand = ( 477 | optionsOrScope 478 | ) => { 479 | const convertedOptions = convertScopeShorthand(optionsOrScope); 480 | // Works because all values are primitives 481 | // eslint-disable-next-line react-compiler/react-compiler 482 | return useMemo(() => convertedOptions, Object.values(convertedOptions)); 483 | }; 484 | 485 | const identity = (x: any) => x; 486 | 487 | export interface CreateAtomStoreOptions< 488 | T extends object, 489 | E extends AtomRecord, 490 | N extends string, 491 | > { 492 | name: N; 493 | delay?: UseAtomOptions['delay']; 494 | effect?: React.FC; 495 | extend?: (atomsWithoutExtend: StoreAtomsWithoutExtend) => E; 496 | infiniteRenderDetectionLimit?: number; 497 | suppressWarnings?: boolean; 498 | } 499 | 500 | /** 501 | * Create an atom store from an initial value. 502 | * Each property will have a getter and setter. 503 | * 504 | * @example 505 | * const { exampleStore, useExampleStore, useExampleValue, useExampleState, useExampleSet } = createAtomStore({ count: 1, say: 'hello' }, { name: 'example' as const }) 506 | * const [count, setCount] = useExampleState() 507 | * const say = useExampleValue('say') 508 | * const setSay = useExampleSet('say') 509 | * setSay('world') 510 | * const countAtom = exampleStore.atom.count 511 | */ 512 | export const createAtomStore = < 513 | T extends object, 514 | E extends AtomRecord, 515 | N extends string = '', 516 | >( 517 | initialState: T, 518 | { 519 | name, 520 | delay: delayRoot, 521 | effect, 522 | extend, 523 | infiniteRenderDetectionLimit = 100_000, 524 | suppressWarnings, 525 | }: CreateAtomStoreOptions 526 | ): AtomStoreApi => { 527 | type MyStoreAtoms = StoreAtoms; 528 | type MyWritableStoreAtoms = WritableStoreAtoms; 529 | type MyStoreAtomsWithoutExtend = StoreAtomsWithoutExtend; 530 | type MyWritableStoreAtomsWithoutExtend = 531 | FilterWritableAtoms; 532 | type MyStoreInitialValues = StoreInitialValues; 533 | 534 | const providerIndex = getProviderIndex(name) as NameProvider; 535 | const useStoreIndex = getUseStoreIndex(name) as UseNameStore; 536 | const storeIndex = getStoreIndex(name) as NameStore; 537 | 538 | const atomsWithoutExtend = {} as MyStoreAtomsWithoutExtend; 539 | const writableAtomsWithoutExtend = {} as MyWritableStoreAtomsWithoutExtend; 540 | const atomIsWritable = {} as Record; 541 | 542 | for (const [key, atomOrValue] of Object.entries(initialState)) { 543 | const atomConfig: Atom = isAtom(atomOrValue) 544 | ? atomOrValue 545 | : atomWithFn(atomOrValue); 546 | atomsWithoutExtend[key as keyof MyStoreAtomsWithoutExtend] = 547 | atomConfig as any; 548 | 549 | const writable = 'write' in atomConfig; 550 | atomIsWritable[key as keyof MyStoreAtoms] = writable; 551 | 552 | if (writable) { 553 | writableAtomsWithoutExtend[ 554 | key as keyof MyWritableStoreAtomsWithoutExtend 555 | ] = atomConfig as any; 556 | } 557 | } 558 | 559 | const atoms = { ...atomsWithoutExtend } as MyStoreAtoms; 560 | 561 | if (extend) { 562 | const extendedAtoms = extend(atomsWithoutExtend); 563 | 564 | for (const [key, atomConfig] of Object.entries(extendedAtoms)) { 565 | atoms[key as keyof MyStoreAtoms] = atomConfig; 566 | atomIsWritable[key as keyof MyStoreAtoms] = 'write' in atomConfig; 567 | } 568 | } 569 | 570 | const atomsOfUseValue = {} as UseValueRecord; 571 | const atomsOfGet = {} as GetRecord; 572 | const atomsOfUseSet = {} as UseSetRecord; 573 | const atomsOfSet = {} as SetRecord; 574 | const atomsOfUseState = {} as UseStateRecord; 575 | const atomsOfSubscribe = {} as SubscribeRecord; 576 | 577 | const useStore = (optionsOrScope: UseAtomOptionsOrScope = {}) => { 578 | const { 579 | scope, 580 | store, 581 | warnIfNoStore = !suppressWarnings, 582 | } = convertScopeShorthand(optionsOrScope); 583 | const contextStore = useAtomStore(name, scope, !store && warnIfNoStore); 584 | return store ?? contextStore; 585 | }; 586 | 587 | let renderCount = 0; 588 | 589 | const useAtomValueWithStore: UseAtomValueFn = ( 590 | atomConfig, 591 | store, 592 | optionsOrScope, 593 | selector, 594 | equalityFnOrDeps, 595 | deps 596 | ) => { 597 | // If selector/equalityFn are not memoized, infinite loop will occur. 598 | if (process.env.NODE_ENV !== 'production' && infiniteRenderDetectionLimit) { 599 | renderCount += 1; 600 | if (renderCount > infiniteRenderDetectionLimit) { 601 | throw new Error( 602 | ` 603 | useValue/useValue/useValue has rendered ${infiniteRenderDetectionLimit} times in the same render. 604 | It is very likely to have fallen into an infinite loop. 605 | That is because you do not memoize the selector/equalityFn function param. 606 | Please wrap them with useCallback or configure the deps array correctly.` 607 | ); 608 | } 609 | // We need to use setTimeout instead of useEffect here, because when infinite loop happens, 610 | // the effect (fired in the next micro task) will execute before the rerender. 611 | setTimeout(() => { 612 | renderCount = 0; 613 | }); 614 | } 615 | 616 | const options = convertScopeShorthand(optionsOrScope); 617 | selector ??= identity; 618 | const equalityFn = 619 | typeof equalityFnOrDeps === 'function' ? equalityFnOrDeps : undefined; 620 | deps = (typeof equalityFnOrDeps === 'function' 621 | ? deps 622 | : equalityFnOrDeps) ?? [selector, equalityFn]; 623 | 624 | const [memoizedSelector, memoizedEqualityFn] = React.useMemo( 625 | () => [selector, equalityFn], 626 | deps 627 | ); 628 | 629 | const selectorAtom = selectAtom( 630 | atomConfig, 631 | memoizedSelector, 632 | memoizedEqualityFn 633 | ) as Atom; 634 | return useAtomValue(selectorAtom, { 635 | store, 636 | delay: options.delay ?? delayRoot, 637 | }); 638 | }; 639 | 640 | const getAtomWithStore: GetAtomFn = (atomConfig, store, _optionsOrScope) => { 641 | return (store ?? getDefaultStore()).get(atomConfig); 642 | }; 643 | 644 | const useSetAtomWithStore: SetAtomFn = ( 645 | atomConfig, 646 | store, 647 | _optionsOrScope 648 | ) => { 649 | return useSetAtom(atomConfig, { store }); 650 | }; 651 | 652 | const setAtomWithStore: UseSetAtomFn = ( 653 | atomConfig, 654 | store, 655 | _optionsOrScope 656 | ) => { 657 | return (...args) => 658 | (store ?? (getDefaultStore() as NonNullable)).set( 659 | atomConfig, 660 | ...args 661 | ); 662 | }; 663 | 664 | const useAtomStateWithStore: UseAtomFn = ( 665 | atomConfig, 666 | store, 667 | optionsOrScope 668 | ) => { 669 | const { delay = delayRoot } = convertScopeShorthand(optionsOrScope); 670 | return useAtom(atomConfig, { store, delay }); 671 | }; 672 | 673 | const subscribeAtomWithStore: SubscribeAtomFn = ( 674 | atomConfig, 675 | store, 676 | _optionsOrScope 677 | ) => { 678 | return (callback) => { 679 | store ??= getDefaultStore(); 680 | const unsubscribe = store.sub(atomConfig, () => { 681 | callback(store!.get(atomConfig)); 682 | }); 683 | return () => unsubscribe(); 684 | }; 685 | }; 686 | 687 | for (const key of Object.keys(atoms)) { 688 | const atomConfig = atoms[key as keyof MyStoreAtoms]; 689 | const isWritable: boolean = atomIsWritable[key as keyof MyStoreAtoms]; 690 | 691 | (atomsOfUseValue as any)[key] = ( 692 | store: JotaiStore | undefined, 693 | optionsOrScope: UseAtomOptionsOrScope = {}, 694 | selector?: (v: any, prevSelectorOutput?: any) => any, 695 | equalityFnOrDeps?: 696 | | ((prevSelectorOutput: any, selectorOutput: any) => boolean) 697 | | unknown[], 698 | deps?: unknown[] 699 | ) => 700 | useAtomValueWithStore( 701 | atomConfig, 702 | store, 703 | optionsOrScope, 704 | selector, 705 | equalityFnOrDeps, 706 | deps 707 | ); 708 | 709 | (atomsOfGet as any)[key] = ( 710 | store: JotaiStore | undefined, 711 | optionsOrScope: UseAtomOptionsOrScope = {} 712 | ) => getAtomWithStore(atomConfig, store, optionsOrScope); 713 | 714 | (atomsOfSubscribe as any)[key] = ( 715 | store: JotaiStore | undefined, 716 | optionsOrScope: UseAtomOptionsOrScope = {}, 717 | callback: (newValue: any) => void 718 | ) => subscribeAtomWithStore(atomConfig, store, optionsOrScope)(callback); 719 | 720 | if (isWritable) { 721 | (atomsOfUseSet as any)[key] = ( 722 | store: JotaiStore | undefined, 723 | optionsOrScope: UseAtomOptionsOrScope = {} 724 | ) => 725 | useSetAtomWithStore( 726 | atomConfig as WritableAtom, 727 | store, 728 | optionsOrScope 729 | ); 730 | 731 | (atomsOfSet as any)[key] = ( 732 | store: JotaiStore | undefined, 733 | optionsOrScope: UseAtomOptionsOrScope = {}, 734 | ...args: any[] 735 | ) => 736 | setAtomWithStore( 737 | atomConfig as WritableAtom, 738 | store, 739 | optionsOrScope 740 | )(...args); 741 | 742 | (atomsOfUseState as any)[key] = ( 743 | store: JotaiStore | undefined, 744 | optionsOrScope: UseAtomOptionsOrScope = {} 745 | ) => 746 | useAtomStateWithStore( 747 | atomConfig as WritableAtom, 748 | store, 749 | optionsOrScope 750 | ); 751 | } 752 | } 753 | 754 | const Provider: React.FC> = 755 | createAtomProvider( 756 | name, 757 | writableAtomsWithoutExtend, 758 | { effect } 759 | ); 760 | 761 | const storeApi: StoreApi = { 762 | atom: atoms, 763 | name, 764 | }; 765 | 766 | const useStoreApi: UseStoreApi = (options = {}) => { 767 | const convertedOptions = useConvertScopeShorthand(options); 768 | const store = useStore(convertedOptions); 769 | 770 | return useMemo( 771 | () => ({ 772 | // store.useValue() 773 | ...(withStoreAndOptions( 774 | atomsOfUseValue, 775 | getUseValueIndex, 776 | store, 777 | convertedOptions 778 | ) as UseKeyValueApis), 779 | // store.get() 780 | ...(withStoreAndOptions( 781 | atomsOfGet, 782 | getGetIndex, 783 | store, 784 | convertedOptions 785 | ) as GetKeyApis), 786 | // store.useSet() 787 | ...(withStoreAndOptions( 788 | atomsOfUseSet, 789 | getUseSetIndex, 790 | store, 791 | convertedOptions 792 | ) as UseSetKeyApis), 793 | // store.set(...args) 794 | ...(withStoreAndOptions( 795 | atomsOfSet, 796 | getSetIndex, 797 | store, 798 | convertedOptions 799 | ) as SetKeyApis), 800 | // store.useState() 801 | ...(withStoreAndOptions( 802 | atomsOfUseState, 803 | getUseStateIndex, 804 | store, 805 | convertedOptions 806 | ) as UseKeyStateApis), 807 | // store.subscribe(callback) 808 | ...(withStoreAndOptions( 809 | atomsOfSubscribe, 810 | getSubscribeIndex, 811 | store, 812 | convertedOptions 813 | ) as SubscribeKeyApis), 814 | // store.useValue('key') 815 | useValue: withKeyAndStoreAndOptions( 816 | atomsOfUseValue, 817 | store, 818 | convertedOptions 819 | ) as UseParamKeyValueApi, 820 | // store.get('key') 821 | get: withKeyAndStoreAndOptions( 822 | atomsOfGet, 823 | store, 824 | convertedOptions 825 | ) as GetParamKeyApi, 826 | // store.useSet('key') 827 | useSet: withKeyAndStoreAndOptions( 828 | atomsOfUseSet, 829 | store, 830 | convertedOptions 831 | ) as UseSetParamKeyApi, 832 | // store.set('key', ...args) 833 | set: withKeyAndStoreAndOptions( 834 | atomsOfSet, 835 | store, 836 | convertedOptions 837 | ) as SetParamKeyApi, 838 | // store.useState('key') 839 | useState: withKeyAndStoreAndOptions( 840 | atomsOfUseState, 841 | store, 842 | convertedOptions 843 | ) as UseParamKeyStateApi, 844 | // store.subscribe('key', callback) 845 | subscribe: withKeyAndStoreAndOptions( 846 | atomsOfSubscribe, 847 | store, 848 | convertedOptions 849 | ) as SubscribeParamKeyApi, 850 | // store.useAtomValue(atomConfig) 851 | useAtomValue: ((atomConfig, selector, equalityFnOrDeps, deps) => 852 | // eslint-disable-next-line react-compiler/react-compiler 853 | useAtomValueWithStore( 854 | atomConfig, 855 | store, 856 | convertedOptions, 857 | selector, 858 | equalityFnOrDeps, 859 | deps 860 | )) as UseAtomParamValueApi, 861 | // store.getAtom(atomConfig) 862 | getAtom: (atomConfig) => 863 | getAtomWithStore(atomConfig, store, convertedOptions), 864 | // store.useSetAtom(atomConfig) 865 | useSetAtom: (atomConfig) => 866 | // eslint-disable-next-line react-compiler/react-compiler 867 | useSetAtomWithStore(atomConfig, store, convertedOptions), 868 | // store.setAtom(atomConfig, ...args) 869 | setAtom: (atomConfig) => 870 | setAtomWithStore(atomConfig, store, convertedOptions), 871 | // store.useAtomState(atomConfig) 872 | useAtomState: (atomConfig) => 873 | // eslint-disable-next-line react-compiler/react-compiler 874 | useAtomStateWithStore(atomConfig, store, convertedOptions), 875 | // store.subscribeAtom(atomConfig, callback) 876 | subscribeAtom: (atomConfig) => 877 | subscribeAtomWithStore(atomConfig, store, convertedOptions), 878 | store, 879 | }), 880 | [store, convertedOptions] 881 | ); 882 | }; 883 | 884 | const useNameState = >( 885 | key: K, 886 | options?: UseAtomOptionsOrScope 887 | ) => { 888 | const store = useStore(options) ?? getDefaultStore(); 889 | return useAtomStateWithStore(atoms[key] as any, store, options); 890 | }; 891 | 892 | const useNameValue = < 893 | K extends keyof StoreAtoms, 894 | S = StoreAtoms[K] extends Atom ? V : never, 895 | >( 896 | key: K, 897 | { 898 | equalityFn, 899 | selector, 900 | ...options 901 | }: UseValueOptions< 902 | StoreAtoms[K] extends Atom ? V : never, 903 | S 904 | > = {}, 905 | deps?: unknown[] 906 | ) => { 907 | const store = useStore(options) ?? getDefaultStore(); 908 | return useAtomValueWithStore( 909 | atoms[key], 910 | store, 911 | options, 912 | selector as any, 913 | equalityFn ?? deps, 914 | equalityFn && deps 915 | ); 916 | }; 917 | 918 | const useNameSet = >( 919 | key: K, 920 | options?: UseAtomOptionsOrScope 921 | ) => { 922 | const store = useStore(options) ?? getDefaultStore(); 923 | return useSetAtomWithStore(atoms[key] as any, store, options); 924 | }; 925 | 926 | return { 927 | [providerIndex]: Provider, 928 | [useStoreIndex]: useStoreApi, 929 | [storeIndex]: storeApi, 930 | [`use${capitalizeFirstLetter(name)}State`]: useNameState, 931 | [`use${capitalizeFirstLetter(name)}Value`]: useNameValue, 932 | [`use${capitalizeFirstLetter(name)}Set`]: useNameSet, 933 | name, 934 | } as any; 935 | }; 936 | 937 | export function useAtomStoreValue>( 938 | store: ReturnOfUseStoreApi, 939 | key: K 940 | ): StoreAtoms[K] extends Atom ? V : never; 941 | export function useAtomStoreValue, S>( 942 | store: ReturnOfUseStoreApi, 943 | key: K, 944 | selector: StoreAtoms[K] extends Atom 945 | ? (v: V, prevSelectorOutput?: S) => S 946 | : never, 947 | deps?: unknown[] 948 | ): S; 949 | export function useAtomStoreValue, S>( 950 | store: ReturnOfUseStoreApi, 951 | key: K, 952 | selector: StoreAtoms[K] extends Atom 953 | ? ((v: V, prevSelectorOutput?: S) => S) | undefined 954 | : never, 955 | equalityFn: (prevSelectorOutput: S, selectorOutput: S) => boolean, 956 | deps?: unknown[] 957 | ): S; 958 | export function useAtomStoreValue, S>( 959 | store: ReturnOfUseStoreApi, 960 | key: K, 961 | selector?: StoreAtoms[K] extends Atom 962 | ? (v: V, prevSelectorOutput?: S) => S 963 | : never, 964 | equalityFnOrDeps?: any, 965 | deps?: unknown[] 966 | ) { 967 | return store.useValue(key, selector, equalityFnOrDeps, deps); 968 | } 969 | 970 | export function useAtomStoreSet>( 971 | store: ReturnOfUseStoreApi, 972 | key: K 973 | ) { 974 | return store.useSet(key); 975 | } 976 | 977 | export function useAtomStoreState>( 978 | store: ReturnOfUseStoreApi, 979 | key: K 980 | ) { 981 | return store.useState(key); 982 | } 983 | 984 | export function useStoreAtomValue( 985 | store: ReturnOfUseStoreApi, 986 | atom: Atom 987 | ): V; 988 | export function useStoreAtomValue( 989 | store: ReturnOfUseStoreApi, 990 | atom: Atom, 991 | selector: (v: V, prevSelectorOutput?: S) => S, 992 | deps?: unknown[] 993 | ): S; 994 | export function useStoreAtomValue( 995 | store: ReturnOfUseStoreApi, 996 | atom: Atom, 997 | selector: ((v: V, prevSelectorOutput?: S) => S) | undefined, 998 | equalityFn: (prevSelectorOutput: S, selectorOutput: S) => boolean, 999 | deps?: unknown[] 1000 | ): S; 1001 | export function useStoreAtomValue( 1002 | store: ReturnOfUseStoreApi, 1003 | atom: Atom, 1004 | selector?: (v: V, prevSelectorOutput?: S) => S, 1005 | equalityFnOrDeps?: any, 1006 | deps?: unknown[] 1007 | ) { 1008 | return store.useAtomValue(atom, selector, equalityFnOrDeps, deps); 1009 | } 1010 | 1011 | export function useStoreSetAtom( 1012 | store: ReturnOfUseStoreApi, 1013 | atom: WritableAtom 1014 | ) { 1015 | return store.useSetAtom(atom); 1016 | } 1017 | 1018 | export function useStoreAtomState( 1019 | store: ReturnOfUseStoreApi, 1020 | atom: WritableAtom 1021 | ) { 1022 | return store.useAtomState(atom); 1023 | } 1024 | -------------------------------------------------------------------------------- /packages/jotai-x/src/elementAtom.spec.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | import React from 'react'; 4 | import { act, render } from '@testing-library/react'; 5 | 6 | import { createAtomStore, useAtomStoreValue } from './createAtomStore'; 7 | 8 | type TElement = any; 9 | 10 | export const SCOPE_ELEMENT = 'element'; 11 | 12 | export type ElementStoreValue = { element: TElement | null }; 13 | 14 | export const { useElementStore, ElementProvider } = createAtomStore( 15 | { element: null } satisfies ElementStoreValue as ElementStoreValue, 16 | { name: 'element' } as const 17 | ); 18 | 19 | /** 20 | * Get the element by plugin key. 21 | * If no element is found in the context, it will return an empty object. 22 | */ 23 | export const useElement = ( 24 | pluginKey = SCOPE_ELEMENT 25 | ): T => { 26 | const store = useElementStore(pluginKey); 27 | const value = useAtomStoreValue(store, 'element'); 28 | 29 | if (!value) { 30 | console.warn( 31 | `The \`useElement(pluginKey)\` hook must be used inside the node component's context` 32 | ); 33 | return {} as T; 34 | } 35 | 36 | return value as T; 37 | }; 38 | 39 | describe('ElementProvider', () => { 40 | interface TNameElement extends TElement { 41 | type: 'name'; 42 | name: string; 43 | } 44 | 45 | interface TAgeElement extends TElement { 46 | type: 'age'; 47 | age: number; 48 | } 49 | 50 | const makeNameElement = (name: string): TNameElement => ({ 51 | type: 'name', 52 | name, 53 | children: [], 54 | }); 55 | 56 | const makeAgeElement = (age: number): TAgeElement => ({ 57 | type: 'age', 58 | age, 59 | children: [], 60 | }); 61 | 62 | const NameElementProvider = ({ 63 | name, 64 | children, 65 | }: { 66 | name: string; 67 | children: React.ReactNode; 68 | }) => { 69 | const element = React.useMemo(() => makeNameElement(name), [name]); 70 | 71 | return ( 72 | 73 | {children} 74 | 75 | ); 76 | }; 77 | 78 | const AgeElementProvider = ({ 79 | age, 80 | children, 81 | }: { 82 | age: number; 83 | children: React.ReactNode; 84 | }) => { 85 | const element = React.useMemo(() => makeAgeElement(age), [age]); 86 | 87 | return ( 88 | 89 | {children} 90 | 91 | ); 92 | }; 93 | 94 | const UpdatingAgeElementProvider = ({ 95 | initialAge, 96 | increment, 97 | buttonLabel, 98 | children, 99 | }: { 100 | initialAge: number; 101 | increment: number; 102 | buttonLabel: string; 103 | children: React.ReactNode; 104 | }) => { 105 | const [age, setAge] = React.useState(initialAge); 106 | 107 | return ( 108 | 109 | 112 | {children} 113 | 114 | ); 115 | }; 116 | 117 | interface ConsumerProps { 118 | label?: string; 119 | } 120 | 121 | const NameElementConsumer = ({ label = '' }: ConsumerProps) => { 122 | const element = useElement('name'); 123 | return
{label + element.name}
; 124 | }; 125 | 126 | const AgeElementConsumer = ({ label = '' }: ConsumerProps) => { 127 | const element = useElement('age'); 128 | return
{label + element.age}
; 129 | }; 130 | 131 | const TypeConsumer = ({ 132 | type, 133 | label = '', 134 | }: ConsumerProps & { type?: 'name' | 'age' }) => { 135 | const element = useElement(type); 136 | return
{label + element.type}
; 137 | }; 138 | 139 | const JsonConsumer = ({ 140 | type, 141 | label = '', 142 | }: ConsumerProps & { type?: 'name' | 'age' }) => { 143 | const element = useElement(type); 144 | return
{label + JSON.stringify(element)}
; 145 | }; 146 | 147 | it('returns the first ancestor matching the element type', () => { 148 | const { getByText } = render( 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | ); 161 | 162 | expect(getByText('Name: Jane')).toBeInTheDocument(); 163 | expect(getByText('Age: 30')).toBeInTheDocument(); 164 | expect(getByText('Type: age')).toBeInTheDocument(); 165 | }); 166 | 167 | it('returns the first ancestor of any type if given type does not match', () => { 168 | const { getByText } = render( 169 | 170 | 171 | 172 | 173 | 174 | ); 175 | 176 | expect(getByText('Type: name')).toBeInTheDocument(); 177 | }); 178 | 179 | it('propagates updated elements to consumers', () => { 180 | const { getByText } = render( 181 | 186 | 187 | 192 | 193 | 194 | 195 | ); 196 | 197 | expect(getByText('Age 1: 20')).toBeInTheDocument(); 198 | expect(getByText('Age 2: 140')).toBeInTheDocument(); 199 | 200 | act(() => getByText('updateAge1').click()); 201 | 202 | expect(getByText('Age 1: 30')).toBeInTheDocument(); 203 | expect(getByText('Age 2: 140')).toBeInTheDocument(); 204 | 205 | act(() => getByText('updateAge2').click()); 206 | 207 | expect(getByText('Age 1: 30')).toBeInTheDocument(); 208 | expect(getByText('Age 2: 150')).toBeInTheDocument(); 209 | 210 | act(() => getByText('updateAge1').click()); 211 | 212 | expect(getByText('Age 1: 40')).toBeInTheDocument(); 213 | expect(getByText('Age 2: 150')).toBeInTheDocument(); 214 | }); 215 | 216 | it('returns empty object if no ancestor exists', () => { 217 | const { getByText } = render(); 218 | expect(getByText('{}')).toBeInTheDocument(); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /packages/jotai-x/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Automatically generated by barrelsby. 3 | */ 4 | 5 | export * from './atomWithFn'; 6 | export * from './createAtomProvider'; 7 | export * from './createAtomStore'; 8 | export * from './useHydrateStore'; 9 | -------------------------------------------------------------------------------- /packages/jotai-x/src/useHydrateStore.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSetAtom } from 'jotai'; 3 | import { useHydrateAtoms } from 'jotai/utils'; 4 | 5 | import { 6 | SimpleWritableAtomRecord, 7 | UseHydrateAtoms, 8 | UseSyncAtoms, 9 | } from './createAtomStore'; 10 | 11 | /** 12 | * Hydrate atoms with initial values for SSR. 13 | */ 14 | export const useHydrateStore = ( 15 | atoms: SimpleWritableAtomRecord, 16 | initialValues: Parameters>[0], 17 | options: Parameters>[1] = {} 18 | ) => { 19 | const values: any[] = []; 20 | 21 | for (const key of Object.keys(atoms)) { 22 | const initialValue = initialValues[key]; 23 | 24 | if (initialValue !== undefined) { 25 | values.push([atoms[key], initialValue]); 26 | } 27 | } 28 | 29 | useHydrateAtoms(values, options); 30 | }; 31 | 32 | /** 33 | * Update atoms with new values on changes. 34 | */ 35 | export const useSyncStore = ( 36 | atoms: SimpleWritableAtomRecord, 37 | values: any, 38 | { store }: Parameters>[1] = {} 39 | ) => { 40 | for (const key of Object.keys(atoms)) { 41 | const value = values[key]; 42 | const atom = atoms[key]; 43 | 44 | // eslint-disable-next-line react-compiler/react-compiler 45 | const set = useSetAtom(atom, { store }); 46 | 47 | // eslint-disable-next-line react-compiler/react-compiler 48 | React.useEffect(() => { 49 | if (value !== undefined && value !== null) { 50 | set(value); 51 | } 52 | }, [set, value]); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /packages/jotai-x/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../config/tsconfig.build.json", 3 | "compilerOptions": { 4 | "declarationDir": "./dist", 5 | "outDir": "./dist" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | const { 2 | getPrettierConfig, 3 | } = require('./config/eslint/helpers/getPrettierConfig.cjs'); 4 | 5 | const config = getPrettierConfig(); 6 | 7 | /** @type {import("prettier").Config} */ 8 | module.exports = { 9 | ...config, 10 | }; 11 | -------------------------------------------------------------------------------- /scripts/styleMock.cjs: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /scripts/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "**/*test*/**", 4 | "**/*fixture*/**", 5 | "**/*template*/**", 6 | "**/*stories*/**", 7 | "**/*.development.*" 8 | ], 9 | "includeVersion": true, 10 | "name": "jotai-x", 11 | "entryPoints": ["../packages/jotai-x/src/index.ts"], 12 | "out": "../typedoc", 13 | "tsconfig": "../packages/jotai-x/tsconfig.json", 14 | "readme": "../README.md" 15 | } 16 | -------------------------------------------------------------------------------- /scripts/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | vi.spyOn(global.console, 'warn').mockImplementation(() => vi.fn()); 4 | vi.spyOn(global.console, 'error').mockImplementation(() => vi.fn()); 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "awesomeTypescriptLoaderOptions": { 3 | "ignoreDiagnostics": [7005] 4 | }, 5 | "compilerOptions": { 6 | "allowJs": true, 7 | "allowSyntheticDefaultImports": true, 8 | "downlevelIteration": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "isolatedModules": true, 12 | "jsx": "react", 13 | "lib": ["dom", "dom.iterable", "esnext"], 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "noEmit": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "sourceMap": true, 20 | "strict": true, 21 | "target": "es2017", 22 | "outDir": "dist", 23 | "types": ["vitest/globals"] 24 | }, 25 | "exclude": ["node_modules", "**/node_modules", "dist", "**/dist"] 26 | } 27 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local", "**/tsconfig*.json"], 4 | "globalEnv": ["CI", "GITHUB_OAUTH_TOKEN"], 5 | "pipeline": { 6 | "build": { 7 | "dependsOn": ["^build"], 8 | "env": ["NODE_ENV", "npm_config_user_agent", "https_proxy"], 9 | "outputs": ["dist/**", ".next/**"] 10 | }, 11 | "build:watch": { 12 | "cache": false, 13 | "persistent": true 14 | }, 15 | "brl": {}, 16 | "clean": { 17 | "cache": false 18 | }, 19 | "lint": {}, 20 | "lint:fix": { 21 | "cache": false 22 | }, 23 | "prebuild": {}, 24 | "typecheck": {}, 25 | "test": { 26 | "dependsOn": [], 27 | "outputs": ["jest.config.cjs"] 28 | }, 29 | "test:watch": { 30 | "dependsOn": [], 31 | "cache": false, 32 | "persistent": true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | 6 | export default defineConfig({ 7 | test: { 8 | coverage: { 9 | exclude: [ 10 | 'node_modules/**', 11 | 'dist/**', 12 | '**/*.d.ts', 13 | 'test/**', 14 | 'vite.config.ts', 15 | ], 16 | provider: 'v8', 17 | reporter: ['text', 'json', 'html'], 18 | }, 19 | environment: 'jsdom', 20 | exclude: ['**/node_modules/**', '**/dist/**'], 21 | globals: true, 22 | include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'], 23 | setupFiles: [resolve(rootDir, './scripts/vitest.setup.ts')], 24 | testTimeout: 50_000, 25 | }, 26 | }); 27 | --------------------------------------------------------------------------------