├── .github └── workflows │ └── deploy-docs.yml ├── .gitignore ├── .zed └── settings.json ├── AGENTS.md ├── LICENSE ├── README.md ├── apps ├── convex-example │ ├── .gitignore │ ├── convex │ │ ├── _generated │ │ │ ├── api.d.ts │ │ │ ├── api.js │ │ │ ├── dataModel.d.ts │ │ │ ├── server.d.ts │ │ │ └── server.js │ │ └── chat.ts │ ├── index.html │ ├── package.json │ ├── src │ │ ├── App.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.mts ├── next-gallery │ ├── .gitignore │ ├── README.md │ ├── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components │ │ └── counter.tsx │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── file.svg │ │ ├── globe.svg │ │ ├── next.svg │ │ ├── vercel.svg │ │ └── window.svg │ └── tsconfig.json └── react-nook-docs │ ├── .gitignore │ ├── README.md │ ├── astro.config.mjs │ ├── package.json │ ├── public │ └── favicon.svg │ ├── src │ ├── assets │ │ ├── hero-dark.svg │ │ └── hero-light.svg │ ├── components │ │ ├── demo-hook-in-a-nook.tsx │ │ ├── demos │ │ │ ├── common.tsx │ │ │ ├── delete-with-nevermind │ │ │ │ ├── delete-with-nevermind.tsx │ │ │ │ ├── hooks.ts │ │ │ │ └── index.astro │ │ │ ├── granular-mount │ │ │ │ ├── ascii.ts │ │ │ │ ├── granular-mount.tsx │ │ │ │ ├── hooks.ts │ │ │ │ └── index.astro │ │ │ ├── swapping-behavior │ │ │ │ ├── index.astro │ │ │ │ └── swapping-behavior.tsx │ │ │ └── timer │ │ │ │ ├── index.astro │ │ │ │ ├── timer.tsx │ │ │ │ └── use-timer.ts │ │ ├── hook-lower.astro │ │ ├── hook-upper.astro │ │ ├── hooks-lower.astro │ │ ├── hooks-upper.astro │ │ ├── nook-lower.astro │ │ ├── nook-upper.astro │ │ ├── nooks-lower.astro │ │ ├── nooks-upper.astro │ │ ├── starlight │ │ │ └── ThemeSelect.astro │ │ └── text-common.ts │ ├── content.config.ts │ ├── content │ │ └── docs │ │ │ ├── demos.mdx │ │ │ ├── guides │ │ │ └── example.md │ │ │ ├── index.mdx │ │ │ └── reference │ │ │ └── example.md │ ├── lib │ │ └── apply-cuts.ts │ └── styles │ │ └── global.css │ └── tsconfig.json ├── biome.json ├── package.json ├── packages └── react-nook │ ├── README.md │ ├── package.json │ ├── src │ ├── callback.ts │ ├── ctx.ts │ ├── debug.ts │ ├── effect.ts │ ├── hook-mock.ts │ ├── index.ts │ ├── memo.ts │ ├── state.ts │ └── types.ts │ ├── tests │ ├── memo.test.tsx │ ├── nook.test.tsx │ └── useEffect-behavior.test.tsx │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json └── vitest.config.ts /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy documentation to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | # Allows you to run this workflow manually from the Actions tab on GitHub. 7 | workflow_dispatch: 8 | 9 | # Allow this job to clone the repo and create a page deployment 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Code Repository 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 1 23 | 24 | - name: Print commit id, message and tag 25 | run: | 26 | git show -s --format='%h %s' 27 | echo "github.ref -> {{ github.ref }}" 28 | 29 | - name: Install pnpm 30 | uses: pnpm/action-setup@v4 31 | with: 32 | run_install: false 33 | 34 | - name: Use Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 22.x 38 | cache: 'pnpm' 39 | 40 | - name: Install dependencies 41 | run: pnpm install --recursive --frozen-lockfile 42 | 43 | - name: Build the docs 44 | run: pnpm -r --filter react-nook-docs build 45 | 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v3 48 | with: 49 | path: ./apps/react-nook-docs/dist 50 | 51 | deploy: 52 | needs: build 53 | runs-on: ubuntu-latest 54 | environment: 55 | name: github-pages 56 | url: ${{ steps.deployment.outputs.page_url }} 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # Node/NPM 5 | node_modules 6 | 7 | # Build artifacts 8 | dist 9 | 10 | # VSCode 11 | .vscode 12 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 | { 6 | "lsp": { 7 | "biome": { 8 | "settings": { 9 | "require_cofig_file": true 10 | } 11 | } 12 | }, 13 | "languages": { 14 | "TypeScript": { 15 | "formatter": { 16 | "language_server": { "name": "biome" } 17 | } 18 | }, 19 | "JavaScript": { 20 | "formatter": { 21 | "language_server": { "name": "biome" } 22 | } 23 | }, 24 | "TSX": { 25 | "formatter": { 26 | "language_server": { "name": "biome" } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agent Guidelines for react-nook 2 | 3 | ## Build/Test/Lint Commands 4 | - **Test**: `pnpm test:unit:watch` (root) or `vitest` for single test runs 5 | - **Build**: `pnpm -C packages/react-nook build` (uses tsdown) 6 | - **Type check**: `pnpm -C packages/react-nook test:types` (tsc --noEmit) 7 | - **Lint**: Uses Biome - run `biome check` or `biome format` 8 | - **Next.js app**: `pnpm -C apps/next-gallery dev/build/lint` 9 | - **Docs**: `pnpm -C apps/react-nook-docs dev/build` 10 | 11 | ## Code Style (Biome Config) 12 | - **Indentation**: 2 spaces (not tabs) 13 | - **Quotes**: Single quotes for JS/TS 14 | - **Imports**: Remove unused imports (enforced) 15 | - **JSX Runtime**: Transparent (import React explicitly when needed) 16 | - **File extensions**: Use `.ts`/`.tsx` extensions in imports 17 | 18 | ## Naming & Patterns 19 | - Nooks use `mount` prefix: `mountState`, `mountEffect`, `mountCallback` 20 | - Template literal syntax: `mountState\`\`(initial)` for nook calls 21 | - Regular function calls for components: `nook(() => ...)` 22 | - Use `readonly` for state tuples: `readonly [T, Setter]` 23 | 24 | ## Error Handling 25 | - Throw descriptive errors with context 26 | - Server-side: Return safe defaults (noop functions, initial values) 27 | - Validate nook usage context before execution -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Iwo Plaza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # ⚛︎ React Nook 4 | 5 | An alternate reality where React hooks can be called conditionally. 6 | 7 | 🚧 **Under Construction** 🚧 8 | 9 |
10 | 11 | ## Getting started 12 | 13 | Install using your favorite package manager: 14 | 15 | ```sh 16 | npm install react-nook 17 | ``` 18 | 19 | Then import it into your project: 20 | 21 | ```ts 22 | import { nook } from 'react-nook'; 23 | 24 | // ✨ Create conditional nooks from your 25 | // existing library of hooks, no rewrite 26 | // necessary! 27 | const mountMyFeature = nook(useMyFeature); 28 | ``` 29 | 30 | ## Documentation 31 | 32 | [Visit the React Nook docs](https://iwoplaza.dev/react-nook) to learn how to give your existing hooks the ability to run conditionally. You can also find demos to play with! 33 | -------------------------------------------------------------------------------- /apps/convex-example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | .env.local 5 | -------------------------------------------------------------------------------- /apps/convex-example/convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | ApiFromModules, 13 | FilterApi, 14 | FunctionReference, 15 | } from "convex/server"; 16 | import type * as chat from "../chat.js"; 17 | 18 | /** 19 | * A utility for referencing Convex functions in your app's API. 20 | * 21 | * Usage: 22 | * ```js 23 | * const myFunctionReference = api.myModule.myFunction; 24 | * ``` 25 | */ 26 | declare const fullApi: ApiFromModules<{ 27 | chat: typeof chat; 28 | }>; 29 | export declare const api: FilterApi< 30 | typeof fullApi, 31 | FunctionReference 32 | >; 33 | export declare const internal: FilterApi< 34 | typeof fullApi, 35 | FunctionReference 36 | >; 37 | -------------------------------------------------------------------------------- /apps/convex-example/convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | -------------------------------------------------------------------------------- /apps/convex-example/convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { AnyDataModel } from "convex/server"; 12 | import type { GenericId } from "convex/values"; 13 | 14 | /** 15 | * No `schema.ts` file found! 16 | * 17 | * This generated code has permissive types like `Doc = any` because 18 | * Convex doesn't know your schema. If you'd like more type safety, see 19 | * https://docs.convex.dev/using/schemas for instructions on how to add a 20 | * schema file. 21 | * 22 | * After you change a schema, rerun codegen with `npx convex dev`. 23 | */ 24 | 25 | /** 26 | * The names of all of your Convex tables. 27 | */ 28 | export type TableNames = string; 29 | 30 | /** 31 | * The type of a document stored in Convex. 32 | */ 33 | export type Doc = any; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | */ 46 | export type Id = 47 | GenericId; 48 | 49 | /** 50 | * A type describing your Convex data model. 51 | * 52 | * This type includes information about what tables you have, the type of 53 | * documents stored in those tables, and the indexes defined on them. 54 | * 55 | * This type is used to parameterize methods like `queryGeneric` and 56 | * `mutationGeneric` to make them type-safe. 57 | */ 58 | export type DataModel = AnyDataModel; 59 | -------------------------------------------------------------------------------- /apps/convex-example/convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | ActionBuilder, 13 | HttpActionBuilder, 14 | MutationBuilder, 15 | QueryBuilder, 16 | GenericActionCtx, 17 | GenericMutationCtx, 18 | GenericQueryCtx, 19 | GenericDatabaseReader, 20 | GenericDatabaseWriter, 21 | } from "convex/server"; 22 | import type { DataModel } from "./dataModel.js"; 23 | 24 | /** 25 | * Define a query in this Convex app's public API. 26 | * 27 | * This function will be allowed to read your Convex database and will be accessible from the client. 28 | * 29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 31 | */ 32 | export declare const query: QueryBuilder; 33 | 34 | /** 35 | * Define a query that is only accessible from other Convex functions (but not from the client). 36 | * 37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 38 | * 39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 41 | */ 42 | export declare const internalQuery: QueryBuilder; 43 | 44 | /** 45 | * Define a mutation in this Convex app's public API. 46 | * 47 | * This function will be allowed to modify your Convex database and will be accessible from the client. 48 | * 49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 51 | */ 52 | export declare const mutation: MutationBuilder; 53 | 54 | /** 55 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 56 | * 57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 58 | * 59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 61 | */ 62 | export declare const internalMutation: MutationBuilder; 63 | 64 | /** 65 | * Define an action in this Convex app's public API. 66 | * 67 | * An action is a function which can execute any JavaScript code, including non-deterministic 68 | * code and code with side-effects, like calling third-party services. 69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 71 | * 72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 74 | */ 75 | export declare const action: ActionBuilder; 76 | 77 | /** 78 | * Define an action that is only accessible from other Convex functions (but not from the client). 79 | * 80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 82 | */ 83 | export declare const internalAction: ActionBuilder; 84 | 85 | /** 86 | * Define an HTTP action. 87 | * 88 | * This function will be used to respond to HTTP requests received by a Convex 89 | * deployment if the requests matches the path and method where this action 90 | * is routed. Be sure to route your action in `convex/http.js`. 91 | * 92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 93 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 94 | */ 95 | export declare const httpAction: HttpActionBuilder; 96 | 97 | /** 98 | * A set of services for use within Convex query functions. 99 | * 100 | * The query context is passed as the first argument to any Convex query 101 | * function run on the server. 102 | * 103 | * This differs from the {@link MutationCtx} because all of the services are 104 | * read-only. 105 | */ 106 | export type QueryCtx = GenericQueryCtx; 107 | 108 | /** 109 | * A set of services for use within Convex mutation functions. 110 | * 111 | * The mutation context is passed as the first argument to any Convex mutation 112 | * function run on the server. 113 | */ 114 | export type MutationCtx = GenericMutationCtx; 115 | 116 | /** 117 | * A set of services for use within Convex action functions. 118 | * 119 | * The action context is passed as the first argument to any Convex action 120 | * function run on the server. 121 | */ 122 | export type ActionCtx = GenericActionCtx; 123 | 124 | /** 125 | * An interface to read from the database within Convex query functions. 126 | * 127 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 128 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 129 | * building a query. 130 | */ 131 | export type DatabaseReader = GenericDatabaseReader; 132 | 133 | /** 134 | * An interface to read from and write to the database within Convex mutation 135 | * functions. 136 | * 137 | * Convex guarantees that all writes within a single mutation are 138 | * executed atomically, so you never have to worry about partial writes leaving 139 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 140 | * for the guarantees Convex provides your functions. 141 | */ 142 | export type DatabaseWriter = GenericDatabaseWriter; 143 | -------------------------------------------------------------------------------- /apps/convex-example/convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | } from "convex/server"; 20 | 21 | /** 22 | * Define a query in this Convex app's public API. 23 | * 24 | * This function will be allowed to read your Convex database and will be accessible from the client. 25 | * 26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 28 | */ 29 | export const query = queryGeneric; 30 | 31 | /** 32 | * Define a query that is only accessible from other Convex functions (but not from the client). 33 | * 34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export const internalQuery = internalQueryGeneric; 40 | 41 | /** 42 | * Define a mutation in this Convex app's public API. 43 | * 44 | * This function will be allowed to modify your Convex database and will be accessible from the client. 45 | * 46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export const mutation = mutationGeneric; 50 | 51 | /** 52 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 53 | * 54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export const internalMutation = internalMutationGeneric; 60 | 61 | /** 62 | * Define an action in this Convex app's public API. 63 | * 64 | * An action is a function which can execute any JavaScript code, including non-deterministic 65 | * code and code with side-effects, like calling third-party services. 66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 68 | * 69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const action = actionGeneric; 73 | 74 | /** 75 | * Define an action that is only accessible from other Convex functions (but not from the client). 76 | * 77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 79 | */ 80 | export const internalAction = internalActionGeneric; 81 | 82 | /** 83 | * Define a Convex HTTP action. 84 | * 85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 86 | * as its second. 87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 88 | */ 89 | export const httpAction = httpActionGeneric; 90 | -------------------------------------------------------------------------------- /apps/convex-example/convex/chat.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { mutation, query } from './_generated/server'; 3 | 4 | export const getMessages = query({ 5 | args: {}, 6 | handler: async (ctx) => { 7 | // Get most recent messages first 8 | const messages = await ctx.db.query('messages').order('desc').take(50); 9 | // Reverse the list so that it's in a chronological order. 10 | return messages.reverse(); 11 | }, 12 | }); 13 | 14 | export const sendMessage = mutation({ 15 | args: { 16 | user: v.string(), 17 | body: v.string(), 18 | }, 19 | handler: async (ctx, args) => { 20 | console.log('This TypeScript function is running on the server.'); 21 | await ctx.db.insert('messages', { 22 | user: args.user, 23 | body: args.body, 24 | }); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /apps/convex-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Convex Chat 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/convex-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convex-example", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "pnpm run --parallel \"/^dev:.*/\"", 6 | "build": "tsc && vite build", 7 | "dev:backend": "convex dev", 8 | "dev:frontend": "vite --open", 9 | "predev": "convex dev --once" 10 | }, 11 | "dependencies": { 12 | "@faker-js/faker": "^8.0.2", 13 | "convex": "1.23.0", 14 | "react": "^19.1.1", 15 | "react-dom": "^19.1.1", 16 | "react-nook": "workspace:" 17 | }, 18 | "devDependencies": { 19 | "@types/babel__core": "^7.20.0", 20 | "@types/node": "^18.17.0", 21 | "@types/react": "^19.1.12", 22 | "@types/react-dom": "^19.1.9", 23 | "@vitejs/plugin-react": "^4.2.1", 24 | "typescript": "~5.0.3", 25 | "vite": "^5.4.12" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/convex-example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { useMutation, useQuery } from 'convex/react'; 3 | import { useEffect, useState } from 'react'; 4 | import { nook, useNook } from 'react-nook'; 5 | import { api } from '../convex/_generated/api'; 6 | 7 | // For demo purposes. In a real app, you'd have real user data. 8 | const NAME = getOrSetFakeName(); 9 | 10 | const mountMessages = nook(() => useQuery(api.chat.getMessages)); 11 | 12 | const App = () => { 13 | const [asleep, setAsleep] = useState(true); 14 | // Mounting messages conditionally 15 | const messages = useNook(() => (asleep ? [] : mountMessages``())); 16 | const sendMessage = useMutation(api.chat.sendMessage); 17 | 18 | const [newMessageText, setNewMessageText] = useState(''); 19 | 20 | useEffect(() => { 21 | // Make sure scrollTo works on button click in Chrome 22 | setTimeout(() => { 23 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); 24 | }, 0); 25 | }, [messages]); 26 | 27 | return ( 28 |
29 |
30 |
31 |

Convex Chat

32 |

33 | Connected as {NAME} 34 |

35 |
36 | 39 |
40 | {messages?.map((message) => ( 41 |
45 |
{message.user}
46 | 47 |

{message.body}

48 |
49 | ))} 50 |
{ 52 | e.preventDefault(); 53 | await sendMessage({ user: NAME, body: newMessageText }); 54 | setNewMessageText(''); 55 | }} 56 | > 57 | { 60 | const text = e.target.value; 61 | setNewMessageText(text); 62 | }} 63 | placeholder="Write a message…" 64 | autoFocus 65 | /> 66 | 69 |
70 |
71 | ); 72 | }; 73 | 74 | function getOrSetFakeName() { 75 | const NAME_KEY = 'tutorial_name'; 76 | const name = sessionStorage.getItem(NAME_KEY); 77 | if (!name) { 78 | const newName = faker.person.firstName(); 79 | sessionStorage.setItem(NAME_KEY, newName); 80 | return newName; 81 | } 82 | return name; 83 | } 84 | 85 | export default App; 86 | -------------------------------------------------------------------------------- /apps/convex-example/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | :root { 6 | --primary: #f35d1c; 7 | --primary-text: #111827; 8 | --secondary-text: #374151; 9 | --tertiary-text: #4b5563; 10 | --secondary-background: #f3f4f6; 11 | --bubbles-background: white; 12 | --bubbles-mine-background: #f35d1c; 13 | --focus-ring: #3b82f680; 14 | 15 | color-scheme: light dark; 16 | } 17 | 18 | @media (prefers-color-scheme: dark) { 19 | :root { 20 | --primary: #f35d1c; 21 | --primary-text: #f9fafb; 22 | --secondary-text: #f3f4f6; 23 | --tertiary-text: #e5e7eb; 24 | --secondary-background: #0f172a; 25 | --bubbles-background: #374151; 26 | --bubbles-mine-background: #f35d1c; 27 | } 28 | } 29 | 30 | html, 31 | body { 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | body { 37 | font-family: 38 | system-ui, 39 | -apple-system, 40 | BlinkMacSystemFont, 41 | "Segoe UI", 42 | Roboto, 43 | Oxygen, 44 | Ubuntu, 45 | Cantarell, 46 | "Open Sans", 47 | "Helvetica Neue", 48 | sans-serif; 49 | -webkit-font-smoothing: antialiased; 50 | background-color: var(--secondary-background); 51 | 52 | padding-top: 96px; 53 | padding-bottom: 72px; 54 | } 55 | 56 | button { 57 | cursor: pointer; 58 | } 59 | 60 | .chat header { 61 | position: fixed; 62 | top: 0; 63 | left: 0; 64 | width: 100%; 65 | z-index: 3; 66 | 67 | display: flex; 68 | align-items: center; 69 | justify-content: center; 70 | flex-direction: column; 71 | gap: 4px; 72 | background: var(--primary); 73 | color: white; 74 | text-align: center; 75 | height: 75px; 76 | } 77 | 78 | .chat header::before, 79 | .chat header::after { 80 | display: block; 81 | content: ""; 82 | position: absolute; 83 | top: 75px; 84 | box-shadow: 0 -40px 0 0 var(--primary); 85 | pointer-events: none; 86 | width: 40px; 87 | height: 80px; 88 | } 89 | 90 | .chat header::before { 91 | left: 0; 92 | border-top-left-radius: 40px; 93 | } 94 | 95 | .chat header::after { 96 | right: 0; 97 | border-top-right-radius: 40px; 98 | } 99 | 100 | .chat header h1 { 101 | font-size: 1.1rem; 102 | font-weight: 500; 103 | letter-spacing: -0.02em; 104 | margin: 0; 105 | } 106 | 107 | .chat header p { 108 | margin: 0; 109 | position: relative; 110 | padding-left: 1.2em; 111 | font-weight: 300; 112 | } 113 | 114 | .chat header p::before, 115 | .chat header p::after { 116 | position: absolute; 117 | top: 20%; 118 | left: 0; 119 | display: inline-block; 120 | 121 | content: ""; 122 | width: 0.7em; 123 | height: 0.7em; 124 | margin-right: 0.5em; 125 | background-color: #81e18c; 126 | border-radius: 50%; 127 | margin-bottom: 1px; 128 | 129 | animation: pulse 2s cubic-bezier(0, 0, 0.2, 1) infinite; 130 | } 131 | 132 | .chat header p::after { 133 | animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; 134 | } 135 | 136 | @media (prefers-reduced-motion) { 137 | .chat header p::after { 138 | display: none; 139 | } 140 | } 141 | 142 | @keyframes ping { 143 | 75%, 144 | 100% { 145 | transform: scale(2); 146 | opacity: 0; 147 | } 148 | } 149 | 150 | .chat header p strong { 151 | font-weight: 500; 152 | } 153 | 154 | .chat header p input { 155 | font-weight: 500; 156 | margin: 0; 157 | padding: 0; 158 | width: 100px; 159 | border-radius: 0; 160 | } 161 | 162 | .chat article { 163 | display: grid; 164 | grid-template-columns: 1fr 1fr; 165 | margin: 24px auto; 166 | max-width: 380px; 167 | padding-left: 16px; 168 | padding-right: calc(16px + 10vw); 169 | animation: 0.15s ease-in-out message; 170 | box-sizing: content-box; 171 | } 172 | 173 | @media (prefers-reduced-motion) { 174 | .chat article { 175 | animation-name: fade; 176 | } 177 | } 178 | 179 | @keyframes message { 180 | from { 181 | opacity: 0; 182 | transform: translateY(30px); 183 | } 184 | } 185 | 186 | @keyframes fade { 187 | from { 188 | opacity: 0; 189 | } 190 | } 191 | 192 | .chat article div { 193 | font-weight: 500; 194 | color: var(--primary-text); 195 | } 196 | 197 | .chat article p { 198 | color: var(--secondary-text); 199 | background-color: var(--bubbles-background); 200 | margin-bottom: 1em; 201 | padding: 20px; 202 | margin: 0.5em 0; 203 | border-radius: 16px; 204 | border-bottom-left-radius: 0; 205 | box-shadow: 206 | 0 1px 3px 0 rgb(0 0 0 / 0.1), 207 | 0 1px 2px -1px rgb(0 0 0 / 0.1); 208 | text-overflow: ellipsis; 209 | /* overflow-x: hidden; */ 210 | line-height: 1.4; 211 | grid-column: 1 / 3; 212 | justify-self: start; 213 | white-space: pre-line; 214 | position: relative; 215 | } 216 | 217 | .chat article.message-mine { 218 | padding-left: calc(16px + 10vw); 219 | padding-right: 16px; 220 | } 221 | 222 | .chat article.message-mine div { 223 | text-align: right; 224 | justify-self: end; 225 | grid-column: 1 / 3; 226 | } 227 | 228 | .chat article.message-mine p { 229 | border-radius: 16px; 230 | border-bottom-right-radius: 0; 231 | background: var(--bubbles-mine-background); 232 | color: white; 233 | justify-self: end; 234 | } 235 | 236 | .chat form { 237 | position: fixed; 238 | bottom: 8px; 239 | left: 8px; 240 | width: calc(100% - 16px); 241 | height: 72px; 242 | box-shadow: 243 | 0 20px 25px -5px rgb(0 0 0 / 0.1), 244 | 0 8px 10px -6px rgb(0 0 0 / 0.1); 245 | background-color: rgba(255, 255, 255, 0.8); 246 | backdrop-filter: blur(24px); 247 | -webkit-backdrop-filter: blur(24px); 248 | border-radius: 16px; 249 | display: flex; 250 | z-index: 3; 251 | } 252 | 253 | @media (prefers-color-scheme: dark) { 254 | .chat form { 255 | background-color: rgba(55, 65, 81, 0.8); 256 | } 257 | } 258 | 259 | .chat input { 260 | color: #111827; 261 | width: 100%; 262 | border: 0; 263 | background: transparent; 264 | font-size: 18px; 265 | padding-left: 20px; 266 | padding-right: 72px; 267 | font-family: inherit; 268 | border: 3px solid transparent; 269 | border-radius: 16px; 270 | } 271 | 272 | .chat input::placeholder { 273 | color: #6b7280; 274 | } 275 | 276 | @media (prefers-color-scheme: dark) { 277 | .chat input { 278 | color: white; 279 | } 280 | 281 | .chat input::placeholder { 282 | color: #9ca3af; 283 | } 284 | } 285 | 286 | .chat input:focus { 287 | outline: 0; 288 | border-color: var(--focus-ring); 289 | } 290 | 291 | .chat form > button { 292 | appearance: none; 293 | width: 48px; 294 | height: 48px; 295 | border: 0; 296 | border-radius: 50%; 297 | position: absolute; 298 | right: 16px; 299 | top: 50%; 300 | transform: translateY(-50%); 301 | color: white; 302 | font-size: 0; 303 | transition: 0.15s ease-in-out opacity; 304 | 305 | background-color: var(--primary); 306 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='white' %3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5' /%3E%3C/svg%3E%0A"); 307 | background-size: 24px; 308 | background-repeat: no-repeat; 309 | background-position: center; 310 | } 311 | 312 | .chat button:disabled { 313 | opacity: 0.7; 314 | } 315 | 316 | article button { 317 | position: absolute; 318 | bottom: -0.5em; 319 | right: -0.5em; 320 | border: 0px; 321 | border-radius: 10px; 322 | padding: 0.3em; 323 | z-index: 2; 324 | min-width: 2em; 325 | min-height: 1em; 326 | } 327 | 328 | article button { 329 | display: none; 330 | cursor: pointer; 331 | } 332 | 333 | article:hover button, 334 | article button:has(span) { 335 | display: block; 336 | } 337 | 338 | article button span { 339 | padding-left: 0.2em; 340 | } 341 | -------------------------------------------------------------------------------- /apps/convex-example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import { ConvexProvider, ConvexReactClient } from "convex/react"; 6 | 7 | const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); 8 | 9 | ReactDOM.createRoot(document.getElementById("root")!).render( 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /apps/convex-example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/convex-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx" 17 | }, 18 | "include": ["./src", "./convex", "vite.config.mts"] 19 | } 20 | -------------------------------------------------------------------------------- /apps/convex-example/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | build: { 8 | chunkSizeWarningLimit: 10000, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /apps/next-gallery/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /apps/next-gallery/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/next-gallery/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwoplaza/react-nook/a1abce73a365e2ea83163cb144ef578a8454ee45/apps/next-gallery/app/favicon.ico -------------------------------------------------------------------------------- /apps/next-gallery/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | -------------------------------------------------------------------------------- /apps/next-gallery/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Geist, Geist_Mono } from 'next/font/google'; 3 | import './globals.css'; 4 | 5 | const geistSans = Geist({ 6 | variable: '--font-geist-sans', 7 | subsets: ['latin'], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: '--font-geist-mono', 12 | subsets: ['latin'], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: 'Next Gallery | React Nook', 17 | description: 18 | 'An example gallery app made to showcase and test out React Hook', 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 31 | {children} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/next-gallery/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Counter from '@/components/counter'; 2 | import Image from 'next/image'; 3 | 4 | export default function Home() { 5 | return ( 6 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /apps/next-gallery/components/counter.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useEffect } from 'react'; 4 | import { nook } from 'react-nook'; 5 | 6 | const mountInterval = nook((callback: () => unknown, interval: number) => { 7 | useEffect(() => { 8 | console.log('Mounted the callback'); 9 | const handle = setInterval(callback, interval); 10 | return () => clearInterval(handle); 11 | }, [callback, interval]); 12 | }); 13 | 14 | const Counter = nook(() => { 15 | const foo = useCallback(() => { 16 | console.log('Hello'); 17 | }, []); 18 | 19 | mountInterval``(foo, 1000); 20 | 21 | return

Hello

; 22 | }); 23 | 24 | export default Counter; 25 | -------------------------------------------------------------------------------- /apps/next-gallery/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /apps/next-gallery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-gallery", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "jotai": "^2.13.0", 13 | "next": "15.4.4", 14 | "react": "19.1.0", 15 | "react-dom": "19.1.0" 16 | }, 17 | "devDependencies": { 18 | "@tailwindcss/postcss": "^4", 19 | "@types/node": "^20", 20 | "@types/react": "^19", 21 | "@types/react-dom": "^19", 22 | "react-nook": "workspace:*", 23 | "tailwindcss": "^4", 24 | "typescript": "^5" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/next-gallery/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /apps/next-gallery/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/next-gallery/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/next-gallery/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/next-gallery/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/next-gallery/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/next-gallery/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /apps/react-nook-docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /apps/react-nook-docs/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | pnpm create astro@latest -- --template starlight 7 | ``` 8 | 9 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 10 | 11 | ## 🚀 Project Structure 12 | 13 | Inside of your Astro + Starlight project, you'll see the following folders and files: 14 | 15 | ``` 16 | . 17 | ├── public/ 18 | ├── src/ 19 | │ ├── assets/ 20 | │ ├── content/ 21 | │ │ └── docs/ 22 | │ └── content.config.ts 23 | ├── astro.config.mjs 24 | ├── package.json 25 | └── tsconfig.json 26 | ``` 27 | 28 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 29 | 30 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 31 | 32 | Static assets, like favicons, can be placed in the `public/` directory. 33 | 34 | ## 🧞 Commands 35 | 36 | All commands are run from the root of the project, from a terminal: 37 | 38 | | Command | Action | 39 | | :------------------------ | :----------------------------------------------- | 40 | | `pnpm install` | Installs dependencies | 41 | | `pnpm dev` | Starts local dev server at `localhost:4321` | 42 | | `pnpm build` | Build your production site to `./dist/` | 43 | | `pnpm preview` | Preview your build locally, before deploying | 44 | | `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` | 45 | | `pnpm astro -- --help` | Get help using the Astro CLI | 46 | 47 | ## 👀 Want to learn more? 48 | 49 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 50 | -------------------------------------------------------------------------------- /apps/react-nook-docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import react from '@astrojs/react'; 3 | import starlight from '@astrojs/starlight'; 4 | import tailwindcss from '@tailwindcss/vite'; 5 | import { defineConfig } from 'astro/config'; 6 | 7 | // https://astro.build/config 8 | export default defineConfig({ 9 | site: 'https://iwoplaza.dev', 10 | base: 'react-nook', 11 | integrations: [ 12 | starlight({ 13 | title: 'React Nook', 14 | customCss: ['./src/styles/global.css'], 15 | social: [ 16 | { 17 | icon: 'github', 18 | label: 'GitHub', 19 | href: 'https://github.com/iwoplaza/react-nook', 20 | }, 21 | { 22 | icon: 'x.com', 23 | label: 'X/Twitter', 24 | href: 'https://x.com/iwoplaza', 25 | }, 26 | ], 27 | sidebar: [ 28 | { 29 | label: 'Guides', 30 | items: [ 31 | // Each item here is one entry in the navigation menu. 32 | { label: 'Example Guide', slug: 'guides/example' }, 33 | ], 34 | }, 35 | { 36 | label: 'Reference', 37 | autogenerate: { directory: 'reference' }, 38 | }, 39 | ], 40 | components: { 41 | ThemeSelect: './src/components/starlight/ThemeSelect.astro', 42 | }, 43 | }), 44 | react(), 45 | ], 46 | vite: { 47 | plugins: [tailwindcss()], 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /apps/react-nook-docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-nook-docs", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/react": "^4.3.0", 14 | "@astrojs/starlight": "^0.35.1", 15 | "@astrojs/starlight-tailwind": "^4.0.1", 16 | "@tailwindcss/vite": "^4.1.11", 17 | "@types/react": "^19.1.8", 18 | "@types/react-dom": "^19.1.6", 19 | "astro": "^5.6.1", 20 | "react": "^19.1.0", 21 | "react-dom": "^19.1.0", 22 | "react-nook": "workspace:", 23 | "sharp": "^0.34.2", 24 | "tailwindcss": "^4.1.11" 25 | } 26 | } -------------------------------------------------------------------------------- /apps/react-nook-docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demo-hook-in-a-nook.tsx: -------------------------------------------------------------------------------- 1 | import { nook } from 'react-nook'; 2 | import { Btn, useCounter } from './demos/common'; 3 | 4 | export const DemoHookInANook = nook(() => { 5 | const [count, increment] = useCounter(); 6 | 7 | return ( 8 |
9 |

Count: {count}

10 | 11 | Increment 12 | 13 |
14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/common.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | export const Btn = (props: { 4 | onClick: () => unknown; 5 | className?: string | undefined; 6 | highlighted?: boolean | undefined; 7 | children?: React.ReactNode | undefined; 8 | }) => { 9 | return ( 10 | 17 | ); 18 | }; 19 | 20 | export function useCounter() { 21 | const [value, setValue] = useState(0); 22 | 23 | const increment = useCallback(() => { 24 | setValue((prev) => prev + 1); 25 | }, []); 26 | 27 | return [value, increment] as const; 28 | } 29 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/delete-with-nevermind/delete-with-nevermind.tsx: -------------------------------------------------------------------------------- 1 | import { nook } from 'react-nook'; 2 | import { Btn } from '../common'; 3 | import { useInterval, useStep } from './hooks'; 4 | 5 | // ---cut-before--- 6 | // Turn any hook into a ✨ nook ✨ 7 | const mountInterval = nook(useInterval); 8 | 9 | // Nooks are just functions that can use other nooks, so they can also be components 10 | const DeleteWithNevermind = nook(() => { 11 | const [step, next] = useStep(); 12 | 13 | if (step === 0) { 14 | // Not deleting... 15 | return next()}>Delete; 16 | } 17 | 18 | if (step < 3) { 19 | // Mounting the interval nook conditionally, which 20 | // will call `next` every second 21 | mountInterval``(next, 1000); 22 | 23 | return ( 24 | <> 25 | next(0)} highlighted> 26 | Bring it back! 27 | 28 |

{step === 1 ? 'Deleting...' : 'You can still stop this...'}

29 | 30 | ); 31 | } 32 | 33 | return ( 34 | <> 35 | next(0)}>Restore 36 |

Deleted

37 | 38 | ); 39 | }); 40 | 41 | // ---cut-after--- 42 | export default () => { 43 | return ( 44 |
45 |
46 | 47 |
48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/delete-with-nevermind/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useInterval(callback: () => unknown, ms: number) { 4 | useEffect(() => { 5 | const handle = window.setInterval(callback, ms); 6 | return () => { 7 | window.clearInterval(handle); 8 | }; 9 | }, [callback, ms]); 10 | } 11 | 12 | export function useStep() { 13 | const [step, setStep] = useState(0); 14 | 15 | const next = (force?: number | undefined) => { 16 | setStep((v) => force ?? v + 1); 17 | }; 18 | 19 | return [step, next] as const; 20 | } 21 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/delete-with-nevermind/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import DeleteWithNevermind from "./delete-with-nevermind"; 3 | import code from "./delete-with-nevermind?raw"; 4 | import code__hooks from "./hooks?raw"; 5 | 6 | import { Code } from "@astrojs/starlight/components"; 7 | import { Tabs, TabItem } from "@astrojs/starlight/components"; 8 | import { applyCuts } from "../../../lib/apply-cuts"; 9 | --- 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/granular-mount/ascii.ts: -------------------------------------------------------------------------------- 1 | export const WALKING_FRAMES = [ 2 | `\ 3 | /\\_/\\__ 4 | (◦°^°◦ )\\ 5 | ' \`\` '`, 6 | `\ 7 | /\\_/\\__ 8 | (◦°^°◦ )\\ 9 | ' \`' \``, 10 | `\ 11 | /\\_/\\__ 12 | (.◦^◦. )\\ 13 | \` '' \``, 14 | `\ 15 | _/\\_/\\_ 16 | ( .◦^◦. )\\ 17 | \` '\` '`, 18 | `\ 19 | _/\\_/\\_ 20 | ( ◦°^°◦ )\\ 21 | ' \`\` '`, 22 | `\ 23 | _/\\_/\\_ 24 | ( ◦°^°◦ )\\ 25 | ' \`' \``, 26 | `\ 27 | _/\\_/\\_ 28 | ( .◦^◦. )\\ 29 | \` '' \``, 30 | `\ 31 | /\\_/\\__ 32 | (.◦^◦. )\\ 33 | \` '\` '`, 34 | ]; 35 | 36 | export const LOOKING_FRAMES = [ 37 | `\ 38 | /\\_/\\ 39 | ( o.o ) 40 | > ^ <`, 41 | `\ 42 | /\\_/\\ 43 | ( -.- ) 44 | > ^ <`, 45 | `\ 46 | /\\_/\\ 47 | (o.o ) 48 | > ^ <`, 49 | `\ 50 | /\\_/\\ 51 | (o.o ) 52 | > ^ <`, 53 | `\ 54 | /\\_/\\ 55 | (o.o ) 56 | > ^ <`, 57 | `\ 58 | /\\_/\\ 59 | (-.- ) 60 | > ^ <`, 61 | `\ 62 | /\\_/\\ 63 | ( -.- ) 64 | > ^ <`, 65 | `\ 66 | /\\_/\\ 67 | ( o.o) 68 | > ^ <`, 69 | `\ 70 | /\\_/\\ 71 | ( o.o) 72 | > ^ <`, 73 | `\ 74 | /\\_/\\ 75 | ( o.o) 76 | > ^ <`, 77 | ]; 78 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/granular-mount/granular-mount.tsx: -------------------------------------------------------------------------------- 1 | // biome-ignore assist/source/organizeImports: better looking in docs 2 | import { Btn } from '../common'; 3 | import { nook, useNook } from 'react-nook'; 4 | import { LOOKING_FRAMES, WALKING_FRAMES } from './ascii'; 5 | import { useInterval, useModuloCounter, useToggle } from './hooks'; 6 | 7 | // ---cut-before--- 8 | // Turn any hook into a ✨ nook ✨ 9 | const mountInterval = nook(useInterval); 10 | 11 | function GranularMount() { 12 | // Animation state 13 | const [walkIdx, incrementWalkIdx] = useModuloCounter(WALKING_FRAMES.length); 14 | const [lookIdx, incrementLookIdx] = useModuloCounter(LOOKING_FRAMES.length); 15 | const [walk, toggleWalk] = useToggle(true); 16 | const [look, toggleLook] = useToggle(true); 17 | 18 | // You can use nooks inside regular components via 19 | // the `useNook` hook 20 | useNook(() => { 21 | walk && mountInterval``(incrementWalkIdx, 100); 22 | look && mountInterval``(incrementLookIdx, 100); 23 | }); 24 | 25 | return ( 26 |
27 |
28 |         {WALKING_FRAMES[walkIdx]}
29 |       
30 |
31 |         {LOOKING_FRAMES[lookIdx]}
32 |       
33 | {walk ? 'Pause' : 'Play'} 34 | {look ? 'Pause' : 'Play'} 35 |
36 | ); 37 | } 38 | 39 | // ---cut-after--- 40 | export default GranularMount; 41 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/granular-mount/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | 3 | export function useModuloCounter(mod: number) { 4 | const [value, setValue] = useState(0); 5 | 6 | const increment = useCallback( 7 | () => setValue((prev) => (prev + 1) % mod), 8 | [mod], 9 | ); 10 | 11 | return [value, increment] as const; 12 | } 13 | 14 | export function useToggle(initial: boolean) { 15 | const [value, setValue] = useState(initial); 16 | const toggle = useCallback(() => setValue((prev) => !prev), []); 17 | 18 | return [value, toggle] as const; 19 | } 20 | 21 | export function useInterval(callback: () => unknown, interval: number) { 22 | // We can use vanilla React hooks inside "nooks", and resort to builtin nooks 23 | // when we need to call something conditionally 24 | useEffect(() => { 25 | const handle = window.setInterval(callback, interval); 26 | return () => window.clearInterval(handle); 27 | }, [interval, callback]); 28 | } 29 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/granular-mount/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { applyCuts } from '../../../lib/apply-cuts'; 3 | import DemoGranularMount from './granular-mount'; 4 | import code from './granular-mount?raw'; 5 | import code__hooks from './hooks?raw'; 6 | // import code__ascii from './ascii?raw'; 7 | 8 | import { Code } from '@astrojs/starlight/components'; 9 | import { Tabs, TabItem } from '@astrojs/starlight/components'; 10 | --- 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 |
26 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/swapping-behavior/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { applyCuts } from '../../../lib/apply-cuts'; 3 | import SwappingBehavior from './swapping-behavior'; 4 | import code from './swapping-behavior?raw'; 5 | 6 | import { Code } from '@astrojs/starlight/components'; 7 | import { Tabs, TabItem } from '@astrojs/starlight/components'; 8 | --- 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/swapping-behavior/swapping-behavior.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { nook } from 'react-nook'; 3 | import { Btn } from '../common'; 4 | 5 | function useIncrement(initial: number) { 6 | const [value, setValue] = useState(initial); 7 | const increment = useCallback(() => setValue((prev) => prev + 1), []); 8 | 9 | return [value, increment] as const; 10 | } 11 | 12 | function useDecrement(initial: number) { 13 | const [value, setValue] = useState(initial); 14 | const increment = useCallback(() => setValue((prev) => prev - 1), []); 15 | 16 | return [value, increment] as const; 17 | } 18 | 19 | const mountIncrement = nook(useIncrement); 20 | const mountDecrement = nook(useDecrement); 21 | 22 | // Nooks are just functions that can use other nooks, so they can also be components! 23 | const DemoSwappingBehavior = nook(() => { 24 | const [mode, setMode] = useState<'increment' | 'decrement'>('increment'); 25 | const [count, action] = 26 | mode === 'increment' ? mountIncrement``(0) : mountDecrement``(100); 27 | 28 | return ( 29 |
30 | setMode('increment')} 33 | > 34 | Increment behavior 35 | 36 | setMode('decrement')} 39 | > 40 | Decrement behavior 41 | 42 |

Count: {count}

43 | 44 | Action 45 | 46 |
47 | ); 48 | }); 49 | 50 | export default DemoSwappingBehavior; 51 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/timer/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Timer from './timer'; 3 | import code from './timer?raw'; 4 | import code__useTimer from './use-timer?raw'; 5 | 6 | import { Code } from '@astrojs/starlight/components'; 7 | import { Tabs, TabItem } from '@astrojs/starlight/components'; 8 | import { applyCuts } from '../../../lib/apply-cuts'; 9 | --- 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/timer/timer.tsx: -------------------------------------------------------------------------------- 1 | // biome-ignore assist/source/organizeImports: better for docs 2 | import { useState } from 'react'; 3 | import { Btn } from '../common'; 4 | import { nook } from 'react-nook'; 5 | import { useTimer } from './use-timer'; 6 | 7 | // ---cut-before--- 8 | // Turn any hook into a ✨ nook ✨ 9 | const mountTimer = nook(useTimer); 10 | 11 | // Nooks are just functions that can use other nooks, so they can also be components 12 | const Timer = nook(() => { 13 | const [active, setActive] = useState(false); 14 | const toggle = () => setActive((prev) => !prev); 15 | 16 | return ( 17 |
18 | {/* Only using `mountTimer` when active, directly in markup */} 19 |

Time: {active ? mountTimer``(100) : 0}

20 | 21 | {active ? 'Stop' : 'Start'} 22 | 23 |
24 | ); 25 | }); 26 | 27 | // ---cut-after--- 28 | export default Timer; 29 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/demos/timer/use-timer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | // A custom React hook, nothing fancy here 4 | export function useTimer(interval: number) { 5 | const [value, setValue] = useState(0); 6 | 7 | useEffect(() => { 8 | const handle = window.setInterval(() => { 9 | setValue((prev) => prev + 1); 10 | }, interval); 11 | 12 | return () => clearInterval(handle); 13 | }, [interval]); 14 | 15 | return value; 16 | } 17 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/hook-lower.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { hookClass } from './text-common'; 3 | --- 4 | 5 | ⚛︎hook 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/hook-upper.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { hookClass } from './text-common'; 3 | --- 4 | 5 | ⚛︎Hook 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/hooks-lower.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { hookClass } from './text-common'; 3 | --- 4 | 5 | ⚛︎hooks 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/hooks-upper.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { hookClass } from './text-common'; 3 | --- 4 | 5 | ⚛︎Hooks 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/nook-lower.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { nookClass } from './text-common'; 3 | --- 4 | 5 | ✳︎nook 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/nook-upper.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { nookClass } from './text-common'; 3 | --- 4 | 5 | Nook 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/nooks-lower.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { nookClass } from './text-common'; 3 | --- 4 | 5 | {/* ⁂▢◇◳◽︎✳︎⬜︎ */} 6 | 7 | ✳︎nooks 8 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/nooks-upper.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { nookClass } from './text-common'; 3 | --- 4 | 5 | ✳︎Nooks 6 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/starlight/ThemeSelect.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import StarlightThemeSelect from "@astrojs/starlight/components/ThemeSelect.astro"; 3 | --- 4 | 5 |
6 | Demos 7 |
8 | 9 | 10 | 11 | 12 | 31 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/components/text-common.ts: -------------------------------------------------------------------------------- 1 | export const hookClass = 'text-accent-800 dark:text-accent-300'; 2 | export const nookClass = 'text-accent-600 dark:text-accent-200'; 3 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsLoader } from '@astrojs/starlight/loaders'; 3 | import { docsSchema } from '@astrojs/starlight/schema'; 4 | 5 | export const collections = { 6 | docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/content/docs/demos.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Demos 3 | description: An alternate reality where React hooks can be called conditionally. 4 | template: splash 5 | --- 6 | 7 | import Timer from '../../components/demos/timer/index.astro'; 8 | import GranularMount from '../../components/demos/granular-mount/index.astro'; 9 | import DeleteWithNevermind from '../../components/demos/delete-with-nevermind/index.astro'; 10 | 11 | ### Granular mount 12 | 13 | 14 | 15 | ### Delete with "nevermind" 16 | 17 | 18 | 19 | ### Simple timer 20 | 21 | 22 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/content/docs/guides/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example Guide 3 | description: A guide in my new Starlight docs site. 4 | --- 5 | 6 | Guides lead a user through a specific task they want to accomplish, often with a sequence of steps. 7 | Writing a good guide requires thinking about what your users are trying to do. 8 | 9 | ## Further reading 10 | 11 | - Read [about how-to guides](https://diataxis.fr/how-to-guides/) in the Diátaxis framework 12 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: React Nook 3 | description: An alternate reality where React hooks can be called conditionally. 4 | template: splash 5 | hero: 6 | tagline: An alternate reality where React hooks can be called conditionally. 7 | image: 8 | alt: A piece of code using React Nook 9 | dark: ../../assets/hero-dark.svg 10 | light: ../../assets/hero-light.svg 11 | actions: 12 | - text: Get started 13 | link: "#getting-started" 14 | icon: right-arrow 15 | - text: Demos 16 | link: /react-nook/demos 17 | icon: puzzle 18 | variant: minimal 19 | attrs: 20 | rel: me 21 | --- 22 | 23 | import { Card, CardGrid } from '@astrojs/starlight/components'; 24 | import Nook from '../../components/nook-lower.astro'; 25 | import NNook from '../../components/nook-upper.astro'; 26 | import Nooks from '../../components/nooks-lower.astro'; 27 | import NNooks from '../../components/nooks-upper.astro'; 28 | import Hook from '../../components/hook-lower.astro'; 29 | import HHook from '../../components/hook-upper.astro'; 30 | import Hooks from '../../components/hooks-lower.astro'; 31 | import HHooks from '../../components/hooks-upper.astro'; 32 | 33 | 34 | You can use nooks surgically where conditionality matters, without having to rewrite any logic around it. **Reuse your existing hooks and call them conditionally!** 35 | You're no longer bound by the *Rules of Hooks*. No more splitting into smaller components just to mount an effect sometimes. 36 | All nook magic happens at runtime, no additional builds tools are necessary. 37 | 38 | 39 | ## Getting started 40 | 41 | :::caution 42 | *React Nook* is in early development. If you encounter any issues, or have feature requests, [don't hesitate to file them in the GitHub repository!](https://github.com/iwoplaza/react-nook/issues) 43 | ::: 44 | :::tip 45 | React Nook requires React 19 or higher. 46 | ::: 47 | 48 | You can use your favorite package manager: 49 | 50 | ```sh 51 | npm install react-nook 52 | ``` 53 | 54 | Then import: 55 | 56 | ```ts 57 | import { ... } from 'react-nook'; 58 | ``` 59 | 60 | Want to jump straight into action? There are various [bite-sized demos](/react-nook/demos) that showcase 61 | specific scenarios where conditional hooks are helpful, and how to use them. If you want a more detailed walkthrough of the features and mechanisms, then keep reading! 62 | 63 | ## The problem with React Hooks 64 | 65 | React = Composable UI, and with the introduction of hooks, that composability soared to the next level! The promise of reusable behavior across components 66 | is not all sunshine and rainbows though. To achieve their goal, the React team had to impose [certain limitations on hook use, known as the "Rules of Hooks"](https://react.dev/reference/rules/rules-of-hooks). This mostly boils down to: 67 | - Only call Hooks at the top level (outside of control-flow) 68 | - Only call Hooks from React functions (components or other hooks) 69 | 70 | The latter is reasonable and I don't think anyone is arguing about it, whereas the former can be a pain when working in bigger projects. 71 | When writing custom hooks, we rarely plan around someone wanting to skip the hook's execution based on a condition. 72 | 73 | ```ts 74 | /** 75 | * @returns seconds passed since mount 76 | */ 77 | export function useSeconds() { 78 | const [value, setValue] = useState(0); 79 | 80 | useEffect(() => { 81 | const handle = setInterval(() => setValue((prev) => prev + 1), 1000); 82 | return () => clearInterval(handle); 83 | }); 84 | 85 | return value; 86 | } 87 | 88 | function Widget() { 89 | const seconds = useSeconds(); 90 | } 91 | ``` 92 | 93 | Now say we want to count up only when the widget is "active". It's easy enough for this simple use case, but for more complex hooks, a refactor 94 | like this can be quite involved. 95 | 96 | ```ts {1, 5, 9} 97 | export function useSeconds(active: boolean) { 98 | const [value, setValue] = useState(0); 99 | 100 | useEffect(() => { 101 | if (!active) return; 102 | 103 | const handle = setInterval(() => setValue((prev) => prev + 1), 1000); 104 | return () => clearInterval(handle); 105 | }, [active]); 106 | 107 | return value; 108 | } 109 | 110 | function Widget(props) { 111 | const seconds = useSeconds(props.active); 112 | } 113 | ``` 114 | 115 | What if we want to reset the count every time it becomes active again? Well then we're out of luck, we have to split the components and mount based 116 | on this condition. **What if we could just use hooks conditionally?** 117 | 118 | ## An enhancement, not a replacement 119 | 120 | Before I explain what are, I want to emphasize that they're not a replacement for , **they make existing hooks better**. 121 | Don't worry about having to rewrite your existing logic. 122 | 123 | :::note[(◦°^°◦ )]{icon="heart"} 124 | You can use *nooks* inside of *hooks*, and *hooks* inside of *nooks*! They're cozy like that. 125 | ::: 126 | 127 | The library also does not replace the React runtime in any way, nor does it require a build-step to work. [React Nook uses a niche property 128 | of tagged template literals, and is described in more details here.](#how-does-it-work) 129 | 130 | {/* ## It's safe to call hooks conditionally in nooks - places where it's safe to call hooks conditionally */} 131 | {/* ## Lay back in the nook, and relax limitations */} 132 | ## Nooks love you conditionally 133 | 134 | are places in your codebase where "Rules of Hooks" can finally lay back and relax... 135 | 136 | Let's go over the relationship between and 137 | - **CANNOT** use other conditionally (the whole reason we're here) 138 | - can use conditionally: `useNook(...)` 139 | - can use other conditionally: `mountFoo``(...)` 140 | - can use conditionally: `nook(useFoo)` 141 | 142 | ### Creating nooks 143 | The easiest way to create a is to wrap an existing . 144 | 145 | ```ts 146 | // A regular React hook 147 | function useToggle(initial: boolean) { 148 | const [value, setValue] = useState(initial); 149 | const toggle = useCallback(() => setValue((prev) => !prev), []); 150 | 151 | return [value, toggle] as const; 152 | } 153 | 154 | // A nook with the same behavior 155 | const mountToggle = nook(useToggle); 156 | ``` 157 | 158 | If you're just now beginning to write these , you may want to write them as to begin with! 159 | 160 | ```ts 161 | const mountToggle = nook((initial: boolean) => { 162 | const [value, setValue] = useState(initial); 163 | const toggle = useCallback(() => setValue((prev) => !prev), []); 164 | 165 | return [value, toggle] as const; 166 | }); 167 | ``` 168 | 169 | The convention is to name nooks starting with `mount` instead of `use`, just to visually differentiate from . 170 | 171 | ### Using nooks 172 | 173 | The API can seem strange as first, but you get used to it. To use `mountFoo` we append an empty template to it, then call the result: 174 | ```ts 175 | mountFoo``(first_arg, second_arg, ...); 176 | ``` 177 | Compared to the version: 178 | ```ts 179 | useFoo(first_arg, second_arg, ...); 180 | ``` 181 | 182 | This small change allows the library to track based on which expression was called, instead of in which order. 183 | [You can learn more about the technical details below](#how-does-it-work) 184 | 185 | #### Inside components or hooks 186 | To use nooks inside regular components or hooks, reach for `useNook`. 187 | 188 | ```ts 189 | function Component(props) { 190 | // ... 191 | const [value, toggle] = useNook(() => { 192 | // Aahh... all cozy and nice 193 | if (props.active) { 194 | return mountToggle``(); 195 | } 196 | // If not active, return a fallback and a noop 197 | return ['Inactive', () => {}]; 198 | }); 199 | // ... 200 | } 201 | ``` 202 | 203 | #### Inside other nooks 204 | 205 | No additional wrapper is necessary in this case: 206 | ```ts 207 | const mountTimer = nook(() => { 208 | const [value, setValue] = useState(0); 209 | // ... 210 | return value; 211 | }); 212 | 213 | const mountTimerWithToggle = nook((initialActive: boolean) => { 214 | const [active, setActive] = useState(initialActive); 215 | // Resets each time we toggle active, because the timer gets unmounted 216 | const time = active ? mountTimer``() : 0; 217 | 218 | const toggle = useCallback(() => setActive((prev) => !prev), []); 219 | 220 | return [time, toggle]; 221 | }); 222 | ``` 223 | 224 | ## Demos 225 | 226 | There's a variety of [bite-sized demos](/react-nook/demos) to check out! 227 | 228 | ## How does it work? 229 | 230 | (explanation-in-progress) 231 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/content/docs/reference/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example Reference 3 | description: A reference page in my new Starlight docs site. 4 | --- 5 | 6 | Reference pages are ideal for outlining how things work in terse and clear terms. 7 | Less concerned with telling a story or addressing a specific use case, they should give a comprehensive outline of what you're documenting. 8 | 9 | ## Further reading 10 | 11 | - Read [about reference](https://diataxis.fr/reference/) in the Diátaxis framework 12 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/lib/apply-cuts.ts: -------------------------------------------------------------------------------- 1 | export function applyCuts(code: string): string { 2 | const cutBeforeMarker = '// ---cut-before---'; 3 | const cutAfterMarker = '// ---cut-after---'; 4 | 5 | let start = code.indexOf(cutBeforeMarker); 6 | if (start >= 0) { 7 | start += cutBeforeMarker.length; 8 | } else { 9 | start = 0; 10 | } 11 | 12 | let end = code.indexOf(cutAfterMarker); 13 | if (end === -1) { 14 | end = code.length; 15 | } 16 | 17 | return code.slice(start, end); 18 | } 19 | -------------------------------------------------------------------------------- /apps/react-nook-docs/src/styles/global.css: -------------------------------------------------------------------------------- 1 | @layer base, starlight, theme, components, utilities; 2 | 3 | @import '@astrojs/starlight-tailwind'; 4 | @import 'tailwindcss/theme.css' layer(theme); 5 | @import 'tailwindcss/utilities.css' layer(utilities); 6 | 7 | @theme { 8 | /* Generated accent color palettes. */ 9 | --color-accent-200: #e7bdba; 10 | --color-accent-600: #b14043; 11 | --color-accent-900: #512121; 12 | --color-accent-950: #381919; 13 | /* Generated gray color palettes. */ 14 | --color-gray-100: #f4f6fa; 15 | --color-gray-200: #eaeef6; 16 | --color-gray-300: #bec2cb; 17 | --color-gray-400: #848b9d; 18 | --color-gray-500: #515868; 19 | --color-gray-700: #323847; 20 | --color-gray-800: #212635; 21 | --color-gray-900: #15181f; 22 | } 23 | -------------------------------------------------------------------------------- /apps/react-nook-docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"], 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "jsxImportSource": "react", 8 | "allowImportingTsExtensions": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", 3 | "files": { 4 | "includes": ["**/*.ts", "**/*.tsx", "**/*.mjs", "!.astro", "**/*.json"] 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { "noUnusedImports": "error" } 11 | } 12 | }, 13 | "json": { 14 | "formatter": { 15 | "indentStyle": "space" 16 | } 17 | }, 18 | "javascript": { 19 | "assist": { 20 | "enabled": true 21 | }, 22 | "jsxRuntime": "transparent", 23 | "formatter": { 24 | "indentStyle": "space", 25 | "quoteStyle": "single" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-nook-monorepo", 3 | "private": true, 4 | "scripts": { 5 | "dev": "pnpm -r --filter react-nook-docs dev", 6 | "fix": "biome check --write", 7 | "test:unit:watch": "vitest", 8 | "test:unit": "vitest run", 9 | "test:types": "pnpm -r test:types" 10 | }, 11 | "keywords": [ 12 | "react", 13 | "hooks", 14 | "hook", 15 | "nooks", 16 | "nook" 17 | ], 18 | "author": { 19 | "name": "Iwo Plaza", 20 | "email": "iwo@iwoplaza.dev", 21 | "url": "https://iwoplaza.dev" 22 | }, 23 | "license": "MIT", 24 | "packageManager": "pnpm@10.14.0-0+sha512.2cd47a0cbf5f1d1de7693a88307a0ede5be94e0d3b34853d800ee775efbea0650cb562b77605ec80bc8d925f5cd27c4dfe8bb04d3a0b76090784c664450d32d6", 25 | "devDependencies": { 26 | "@biomejs/biome": "^2.1.2", 27 | "@testing-library/jest-dom": "^6.6.4", 28 | "@testing-library/react": "^16.3.0", 29 | "@vitejs/plugin-react": "catalog:test", 30 | "@vitest/browser": "catalog:test", 31 | "jsdom": "^26.1.0", 32 | "playwright": "catalog:test", 33 | "react": "^19.1.0", 34 | "vitest": "catalog:test", 35 | "vitest-browser-react": "catalog:test" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/react-nook/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | # ⚛︎ React Nook 6 | 7 | An alternate reality where React hooks can be called conditionally. 8 | 9 | 🚧 **Under Construction** 🚧 10 | 11 |
12 | 13 | ## Getting started 14 | 15 | Install using your favorite package manager: 16 | 17 | ```sh 18 | npm install react-nook 19 | ``` 20 | 21 | Then import it into your project: 22 | 23 | ```ts 24 | import { nook } from 'react-nook'; 25 | 26 | // ✨ Create conditional nooks from your 27 | // existing library of hooks, no rewrite 28 | // necessary! 29 | const mountMyFeature = nook(useMyFeature); 30 | ``` 31 | 32 | ## Documentation 33 | 34 | [Visit the React Nook docs](https://iwoplaza.dev/react-nook) to learn how to give your existing hooks the ability to run conditionally. You can also find demos to play with! 35 | 36 | -------------------------------------------------------------------------------- /packages/react-nook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-nook", 3 | "version": "0.2.0", 4 | "description": "An alternate reality where React hooks can be called conditionally.", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsdown", 8 | "test:types": "tsc --noEmit", 9 | "prepublishOnly": "automd && pnpm build" 10 | }, 11 | "main": "./src/index.ts", 12 | "types": "./src/index.ts", 13 | "publishConfig": { 14 | "main": "./dist/index.js", 15 | "types": "./dist/index.d.ts", 16 | "exports": { 17 | "types": "./dist/index.d.ts", 18 | "default": "./dist/index.js" 19 | } 20 | }, 21 | "files": [ 22 | "README.md", 23 | "LICENSE", 24 | "dist/**" 25 | ], 26 | "keywords": [ 27 | "react", 28 | "hooks", 29 | "hook", 30 | "nooks", 31 | "nook" 32 | ], 33 | "author": { 34 | "name": "Iwo Plaza", 35 | "email": "iwo@iwoplaza.dev", 36 | "url": "https://iwoplaza.dev" 37 | }, 38 | "license": "MIT", 39 | "packageManager": "pnpm@10.14.0-0+sha512.2cd47a0cbf5f1d1de7693a88307a0ede5be94e0d3b34853d800ee775efbea0650cb562b77605ec80bc8d925f5cd27c4dfe8bb04d3a0b76090784c664450d32d6", 40 | "devDependencies": { 41 | "@types/react": "^19.1.8", 42 | "automd": "^0.4.0", 43 | "react": "^19.1.0", 44 | "tsdown": "catalog:build", 45 | "typescript": "catalog:types" 46 | }, 47 | "peerDependencies": { 48 | "react": "^19.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/react-nook/src/callback.ts: -------------------------------------------------------------------------------- 1 | import { CTX } from './ctx.ts'; 2 | import type { AnyFn, CallbackStore } from './types.ts'; 3 | 4 | export function callExpressionTrackedCallback( 5 | callId: object, 6 | callback: T, 7 | deps: unknown[] | undefined, 8 | ): T { 9 | const scope = CTX.parentScope; 10 | if (!scope) { 11 | throw new Error('Invalid state'); 12 | } 13 | 14 | let store = scope.children.get(callId) as CallbackStore | undefined; 15 | if (!store) { 16 | store = { 17 | callback, 18 | deps, 19 | }; 20 | scope.children.set(callId, store); 21 | } else { 22 | // Please don't delete me during this render 23 | scope.scheduledDestroys.delete(callId); 24 | } 25 | 26 | // Comparing with the previous render 27 | if ( 28 | // Not tracking deps? 29 | !store.deps || 30 | !deps || 31 | // Deps changed? 32 | store.deps.length !== deps.length || 33 | store.deps.some((v, idx) => deps[idx] !== v) 34 | ) { 35 | store.callback = callback; 36 | } 37 | 38 | return store.callback; 39 | } 40 | 41 | export function callOrderTrackedCallback( 42 | callback: T, 43 | deps: unknown[] | undefined, 44 | ): T { 45 | const scope = CTX.parentScope; 46 | if (!scope) { 47 | throw new Error('Invalid state'); 48 | } 49 | 50 | const hookIdx = ++scope.lastHookIndex; 51 | let store = scope.hookStores[hookIdx] as CallbackStore | undefined; 52 | if (!store) { 53 | store = { 54 | callback, 55 | deps, 56 | }; 57 | scope.hookStores[hookIdx] = store; 58 | } 59 | 60 | // Comparing with the previous render 61 | if ( 62 | // Not tracking deps? 63 | !store.deps || 64 | !deps || 65 | // Deps changed? 66 | store.deps.length !== deps.length || 67 | store.deps.some((v, idx) => deps[idx] !== v) 68 | ) { 69 | store.callback = callback; 70 | } 71 | 72 | return store.callback; 73 | } 74 | -------------------------------------------------------------------------------- /packages/react-nook/src/ctx.ts: -------------------------------------------------------------------------------- 1 | import type { RootScope, Scope } from './types.ts'; 2 | 3 | export const CTX: { 4 | rootScope: RootScope | undefined; 5 | parentScope: Scope | undefined; 6 | /** 7 | * Used to track whether or not we already requested a re-render during the... render. 8 | * Because we use a flip-flop mechanism, this is required. 9 | */ 10 | rerenderRequested: boolean; 11 | rerender: ((...args: never[]) => unknown) | undefined; 12 | } = { 13 | rootScope: undefined, 14 | parentScope: undefined, 15 | rerender: undefined, 16 | rerenderRequested: false, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/react-nook/src/debug.ts: -------------------------------------------------------------------------------- 1 | type ExtGlobal = typeof globalThis & { 2 | __REACT_NOOK__DBG?: (msg: string, ...args: unknown[]) => void; 3 | }; 4 | 5 | const extGlobal = globalThis as ExtGlobal; 6 | 7 | // Uncomment if you want debug info 8 | // extGlobal.__REACT_NOOK__DBG = (...args) => console.log(...args); 9 | 10 | export function DEBUG(msg: string, ...args: unknown[]) { 11 | extGlobal.__REACT_NOOK__DBG?.(msg, ...args); 12 | } 13 | -------------------------------------------------------------------------------- /packages/react-nook/src/effect.ts: -------------------------------------------------------------------------------- 1 | import { CTX } from './ctx.ts'; 2 | import { DEBUG } from './debug.ts'; 3 | import type { EffectCleanup, EffectStore } from './types.ts'; 4 | 5 | export function callExpressionTrackedEffect( 6 | callId: object, 7 | callback: () => EffectCleanup, 8 | deps: unknown[] | undefined, 9 | ): void { 10 | if (typeof window === 'undefined') { 11 | // We're on the server, no need to do anything 12 | return; 13 | } 14 | 15 | const scope = CTX.parentScope; 16 | const rootScope = CTX.rootScope; 17 | if (!scope || !rootScope) { 18 | throw new Error('Invalid state'); 19 | } 20 | 21 | let store = scope.children.get(callId) as EffectStore | undefined; 22 | 23 | if (!store) { 24 | // First time mounting this effect! 25 | let cleanup: EffectCleanup | undefined; 26 | 27 | store = { 28 | deps, 29 | mount() { 30 | this.unmount(); 31 | DEBUG('mounting'); 32 | cleanup = callback(); 33 | }, 34 | unmount() { 35 | if (cleanup) { 36 | cleanup(); 37 | cleanup = undefined; 38 | } 39 | }, 40 | destroy() { 41 | rootScope.effects.delete(this); 42 | this.unmount(); 43 | }, 44 | }; 45 | rootScope.effects.add(store); 46 | rootScope.dirtyEffects.add(store); 47 | rootScope.effectsFlushed = false; 48 | scope.children.set(callId, store); 49 | } else { 50 | // Please don't destroy me during this render 51 | scope.scheduledDestroys.delete(callId); 52 | } 53 | 54 | // Comparing with the previous render 55 | if ( 56 | !store.deps || 57 | !deps || 58 | store.deps.length !== deps.length || 59 | store.deps.some((v, idx) => deps[idx] !== v) 60 | ) { 61 | rootScope.dirtyEffects.add(store); 62 | rootScope.effectsFlushed = false; 63 | } 64 | 65 | // Update deps for next comparison 66 | store.deps = deps; 67 | } 68 | 69 | export function callOrderTrackedEffect( 70 | callback: () => EffectCleanup, 71 | deps: unknown[] | undefined, 72 | ) { 73 | if (typeof window === 'undefined') { 74 | // We're on the server, no need to do anything 75 | return; 76 | } 77 | 78 | const scope = CTX.parentScope; 79 | const rootScope = CTX.rootScope; 80 | if (!scope || !rootScope) { 81 | throw new Error('Invalid state'); 82 | } 83 | 84 | const hookIdx = ++scope.lastHookIndex; 85 | let store = scope.hookStores[hookIdx] as EffectStore | undefined; 86 | if (!store) { 87 | // First time mounting this effect! 88 | let cleanup: EffectCleanup | undefined; 89 | store = { 90 | deps, 91 | mount() { 92 | this.unmount(); 93 | DEBUG('mounting'); 94 | cleanup = callback(); 95 | }, 96 | unmount() { 97 | if (cleanup) { 98 | cleanup(); 99 | cleanup = undefined; 100 | } 101 | }, 102 | destroy() { 103 | DEBUG('destroying effect'); 104 | this.unmount(); 105 | }, 106 | }; 107 | rootScope.dirtyEffects.add(store); 108 | rootScope.effectsFlushed = false; 109 | scope.hookStores[hookIdx] = store; 110 | } 111 | 112 | // Comparing with the previous render 113 | if ( 114 | !store.deps || 115 | !deps || 116 | store.deps.length !== deps.length || 117 | store.deps.some((v, idx) => deps[idx] !== v) 118 | ) { 119 | rootScope.dirtyEffects.add(store); 120 | rootScope.effectsFlushed = false; 121 | } 122 | 123 | // Update deps for next comparison 124 | store.deps = deps; 125 | } 126 | -------------------------------------------------------------------------------- /packages/react-nook/src/hook-mock.ts: -------------------------------------------------------------------------------- 1 | import React, { use } from 'react'; 2 | import { callOrderTrackedCallback } from './callback.ts'; 3 | import { CTX } from './ctx.ts'; 4 | import { callOrderTrackedEffect } from './effect.ts'; 5 | import { callOrderTrackedMemo } from './memo.ts'; 6 | import { callOrderTrackedState } from './state.ts'; 7 | import type { AnyFn, EffectCleanup } from './types.ts'; 8 | 9 | const NOOP = () => {}; 10 | 11 | const ReactSecretInternals = 12 | //@ts-ignore 13 | React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? 14 | //@ts-ignore 15 | React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; 16 | 17 | let WARNED = false; 18 | 19 | export function mockHooks() { 20 | if (typeof window === 'undefined') { 21 | // We're on the server, there's going to be no rerenders, so no problem with 22 | // conditions in the render function. Skip mocking 23 | return NOOP; 24 | } 25 | 26 | const Dispatcher = 27 | ReactSecretInternals?.H ?? 28 | ReactSecretInternals?.ReactCurrentDispatcher.current; 29 | 30 | const scope = CTX.parentScope; 31 | if (!scope || !Dispatcher) { 32 | // Let's just not mock anything 33 | if (!WARNED) { 34 | console.warn( 35 | 'Cannot nook-ify hooks in this environment. Please file an issue on GitHub', 36 | ); 37 | console.dir(ReactSecretInternals); 38 | WARNED = true; 39 | } 40 | return NOOP; 41 | } 42 | 43 | const originals = Object.entries(Dispatcher).filter(([key]) => key !== 'use'); 44 | for (const [key] of originals) { 45 | Dispatcher[key] = () => { 46 | throw new Error( 47 | `Cannot use '${key}' inside nooks yet. Please file an issue and tell us about your use-case.`, 48 | ); 49 | }; 50 | } 51 | 52 | Dispatcher.useState = (valueOrCompute: unknown) => 53 | callOrderTrackedState(valueOrCompute); 54 | 55 | Dispatcher.useEffect = ( 56 | cb: () => EffectCleanup, 57 | deps?: unknown[] | undefined, 58 | ) => { 59 | callOrderTrackedEffect(cb, deps); 60 | }; 61 | 62 | Dispatcher.useCallback = (cb: AnyFn, deps?: unknown[] | undefined) => 63 | callOrderTrackedCallback(cb, deps); 64 | 65 | Dispatcher.useMemo = (cb: AnyFn, deps?: unknown[] | undefined) => 66 | callOrderTrackedMemo(cb, deps); 67 | 68 | Dispatcher.useContext = (ctx: React.Context) => { 69 | return use(ctx); 70 | }; 71 | 72 | return () => { 73 | for (const [key, value] of originals) { 74 | Dispatcher[key] = value; 75 | } 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /packages/react-nook/src/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { callExpressionTrackedCallback } from './callback.ts'; 3 | import { CTX } from './ctx.ts'; 4 | import { DEBUG } from './debug.ts'; 5 | import { callExpressionTrackedEffect } from './effect.ts'; 6 | import { mockHooks } from './hook-mock.ts'; 7 | import { callExpressionTrackedMemo } from './memo.ts'; 8 | import { callExpressionTrackedState } from './state.ts'; 9 | import type { AnyFn, EffectCleanup, RootScope, Scope } from './types.ts'; 10 | 11 | function createRootScope(): RootScope { 12 | return { 13 | depth: 0, 14 | // Expression-tracking 15 | children: new Map(), 16 | scopes: new Map(), 17 | // Order-tracking 18 | lastHookIndex: -1, 19 | hookStores: [], 20 | 21 | effectsFlushed: true, 22 | dirtyEffects: new Set(), 23 | effects: new Set(), 24 | 25 | scheduledDestroys: new Map(), 26 | 27 | destroy() { 28 | for (const child of this.children.values()) { 29 | child.destroy?.(); 30 | } 31 | this.children.clear(); 32 | 33 | for (const store of this.hookStores) { 34 | store.destroy?.(); 35 | } 36 | this.hookStores = []; 37 | }, 38 | }; 39 | } 40 | 41 | function createScope(depth: number = 0): Scope { 42 | DEBUG(`Creating scope of depth ${depth}`); 43 | return { 44 | depth, 45 | // Expression-tracking 46 | children: new Map(), 47 | scopes: new Map(), 48 | // Order-tracking 49 | lastHookIndex: -1, 50 | hookStores: [], 51 | 52 | scheduledDestroys: new Map(), 53 | 54 | destroy() { 55 | for (const child of this.children.values()) { 56 | child.destroy?.(); 57 | } 58 | this.children.clear(); 59 | 60 | for (const store of this.hookStores) { 61 | store.destroy?.(); 62 | } 63 | this.hookStores = []; 64 | }, 65 | }; 66 | } 67 | 68 | function setupScope(cb: () => T): T { 69 | const scope = CTX.parentScope; 70 | if (!scope) { 71 | throw new Error( 72 | 'Nooks can only be called within other nooks, or via a `useNook` call', 73 | ); 74 | } 75 | 76 | DEBUG(`Setting up scope of depth ${scope.depth}`); 77 | scope.scheduledDestroys = new Map(scope.children.entries()); 78 | scope.lastHookIndex = -1; 79 | 80 | const result = cb(); 81 | 82 | return result; 83 | } 84 | 85 | function flushScheduledDestroys(scope: Scope) { 86 | // Doing it recursively for nested scopes 87 | for (const nested of scope.scopes.values()) { 88 | flushScheduledDestroys(nested); 89 | } 90 | 91 | // Deleting all children that have not checked in 92 | for (const [callId, disposable] of scope.scheduledDestroys.entries()) { 93 | disposable.destroy?.(); 94 | scope.children.delete(callId); 95 | scope.scopes.delete(callId); // Might not be a scope, but does not matter 96 | } 97 | } 98 | 99 | function useTopLevelScope(cb: () => T): T { 100 | DEBUG('useTopLevelScope()'); 101 | // biome-ignore lint/correctness/useHookAtTopLevel: this is not a normal component 102 | const [, rerender] = useState(0); 103 | // biome-ignore lint/correctness/useHookAtTopLevel: this is not a normal component 104 | CTX.rerender = useCallback(() => { 105 | if (CTX.rerenderRequested) return; 106 | 107 | rerender((prev) => 1 - prev); 108 | CTX.rerenderRequested = true; 109 | }, []); 110 | 111 | // This useState calls `createRootScope` two times before continuing the render the first 112 | // time the component is mounted in strict mode. 113 | // biome-ignore lint/correctness/useHookAtTopLevel: this is not a normal component 114 | const [scope] = useState(createRootScope); 115 | CTX.rootScope = scope; 116 | CTX.parentScope = scope; 117 | CTX.rerenderRequested = false; 118 | 119 | if (scope.effectsFlushed) { 120 | // Resetting the set of dirty effects only if 121 | // the previous set has already been flushed. 122 | scope.dirtyEffects = new Set(); 123 | } 124 | 125 | let result: T; 126 | try { 127 | result = setupScope(cb); 128 | } finally { 129 | // Cleanup 130 | CTX.rootScope = undefined; 131 | CTX.parentScope = undefined; 132 | CTX.rerender = undefined; 133 | } 134 | 135 | // biome-ignore lint/correctness/useHookAtTopLevel: this is not a normal component 136 | useEffect(() => { 137 | for (const effect of scope.effects) { 138 | effect.mount(); 139 | } 140 | 141 | return () => { 142 | for (const effect of scope.effects) { 143 | effect.unmount(); 144 | } 145 | }; 146 | }); 147 | 148 | // This effect should run after every render 149 | // biome-ignore lint/correctness/useHookAtTopLevel: this is not a normal component 150 | useEffect(() => { 151 | DEBUG('useEffect()'); 152 | flushScheduledDestroys(scope); 153 | 154 | scope.effectsFlushed = true; 155 | // Cloning the effects here, so that we can unmount 156 | // exactly those that were mounted before. 157 | const dirtyEffects = [...scope.dirtyEffects]; 158 | for (const effect of dirtyEffects) { 159 | effect.mount(); 160 | } 161 | 162 | DEBUG('end of useEffect()'); 163 | }); 164 | 165 | return result; 166 | } 167 | 168 | function withScope(callId: object, callback: () => T): T { 169 | if (!CTX.parentScope) { 170 | throw new Error( 171 | 'Nooks can only be called within other nooks, or via a `useNook` call', 172 | ); 173 | } 174 | let scope = CTX.parentScope.children.get(callId) as Scope | undefined; 175 | if (!scope) { 176 | scope = createScope(CTX.parentScope.depth + 1); 177 | CTX.parentScope.children.set(callId, scope); 178 | CTX.parentScope.scopes.set(callId, scope); 179 | } else { 180 | // Please don't delete me during this render 181 | CTX.parentScope.scheduledDestroys.delete(callId); 182 | } 183 | 184 | const prevParentScope = CTX.parentScope; 185 | CTX.parentScope = scope; 186 | const unmock = mockHooks(); 187 | 188 | let result: T; 189 | try { 190 | result = setupScope(callback); 191 | } finally { 192 | unmock(); 193 | CTX.parentScope = prevParentScope; 194 | } 195 | 196 | return result; 197 | } 198 | 199 | export const mountState = 200 | (strings: TemplateStringsArray) => 201 | (initialOrCompute: T | (() => T)) => 202 | callExpressionTrackedState(strings, initialOrCompute); 203 | 204 | export const mountEffect = 205 | (strings: TemplateStringsArray) => 206 | (callback: () => EffectCleanup, deps?: unknown[] | undefined) => 207 | callExpressionTrackedEffect(strings, callback, deps); 208 | 209 | export const mountCallback = 210 | (strings: TemplateStringsArray) => 211 | (callback: T, deps?: unknown[] | undefined) => 212 | callExpressionTrackedCallback(strings, callback, deps); 213 | 214 | export const mountMemo = 215 | (strings: TemplateStringsArray) => 216 | (factory: () => T, deps?: unknown[] | undefined) => 217 | callExpressionTrackedMemo(strings, factory, deps); 218 | 219 | interface Nook { 220 | (strings: TemplateStringsArray): (...args: TArgs) => TReturn; 221 | (...args: TArgs): TReturn; 222 | } 223 | 224 | export function nook( 225 | def: (...args: TArgs) => TReturn, 226 | ): Nook { 227 | return ((maybeStrings: unknown) => { 228 | if (Array.isArray(maybeStrings)) { 229 | // Calling it as a nook inside another nook, with the foo``() syntax 230 | // --- 231 | 232 | return (...args: TArgs) => withScope(maybeStrings, () => def(...args)); 233 | } 234 | 235 | // Calling it as a component 236 | // --- 237 | 238 | // @ts-ignore: let's assume that if not called using the foo``() syntax, it's being called as a component 239 | // biome-ignore lint/correctness/useHookAtTopLevel: this is not a normal component 240 | return useTopLevelScope(() => def(maybeStrings)); 241 | }) as Nook; 242 | } 243 | 244 | export function useNook(callback: () => T): T { 245 | if (CTX.parentScope) { 246 | // We're already inside of a nook, noop 247 | return callback(); 248 | } 249 | // biome-ignore lint/correctness/useHookAtTopLevel: the order will not change during rendering, it's stable 250 | return useTopLevelScope(callback); 251 | } 252 | -------------------------------------------------------------------------------- /packages/react-nook/src/memo.ts: -------------------------------------------------------------------------------- 1 | import { CTX } from './ctx.ts'; 2 | import type { MemoStore } from './types.ts'; 3 | 4 | export function callExpressionTrackedMemo( 5 | callId: object, 6 | factory: () => T, 7 | deps: unknown[] | undefined, 8 | ): T { 9 | const scope = CTX.parentScope; 10 | if (!scope) { 11 | throw new Error('Invalid state'); 12 | } 13 | 14 | let store = scope.children.get(callId) as MemoStore | undefined; 15 | if (!store) { 16 | store = { 17 | value: factory(), 18 | deps, 19 | }; 20 | scope.children.set(callId, store); 21 | } else { 22 | scope.scheduledDestroys.delete(callId); 23 | } 24 | 25 | if ( 26 | !store.deps || 27 | !deps || 28 | store.deps.length !== deps.length || 29 | store.deps.some((v, idx) => deps[idx] !== v) 30 | ) { 31 | store.value = factory(); 32 | store.deps = deps; 33 | } 34 | 35 | return store.value; 36 | } 37 | 38 | export function callOrderTrackedMemo( 39 | factory: () => T, 40 | deps: unknown[] | undefined, 41 | ): T { 42 | const scope = CTX.parentScope; 43 | if (!scope) { 44 | throw new Error('Invalid state'); 45 | } 46 | 47 | const hookIdx = ++scope.lastHookIndex; 48 | let store = scope.hookStores[hookIdx] as MemoStore | undefined; 49 | if (!store) { 50 | store = { 51 | value: factory(), 52 | deps, 53 | }; 54 | scope.hookStores[hookIdx] = store; 55 | } 56 | 57 | if ( 58 | !store.deps || 59 | !deps || 60 | store.deps.length !== deps.length || 61 | store.deps.some((v, idx) => deps[idx] !== v) 62 | ) { 63 | store.value = factory(); 64 | store.deps = deps; 65 | } 66 | 67 | return store.value; 68 | } 69 | -------------------------------------------------------------------------------- /packages/react-nook/src/state.ts: -------------------------------------------------------------------------------- 1 | import { CTX } from './ctx.ts'; 2 | import type { Setter, StateStore } from './types.ts'; 3 | 4 | export function callExpressionTrackedState( 5 | callId: object, 6 | initial: T, 7 | ): readonly [T, Setter] { 8 | if (typeof window === 'undefined') { 9 | // We're on the server, just return the initial value and a noop 10 | return [initial, () => {}]; 11 | } 12 | 13 | const rerender = CTX.rerender; 14 | const scope = CTX.parentScope; 15 | 16 | if (!scope) { 17 | throw new Error('Invalid state'); 18 | } 19 | 20 | let cachedStore = scope.children.get(callId) as StateStore | undefined; 21 | if (!cachedStore) { 22 | const store: StateStore = { 23 | value: initial, 24 | setter: (valueOrCompute: T | ((prev: T) => T)) => { 25 | if (typeof valueOrCompute === 'function') { 26 | store.value = (valueOrCompute as (prev: T) => T)(store.value); 27 | } else { 28 | store.value = valueOrCompute as T; 29 | } 30 | rerender?.(); 31 | }, 32 | }; 33 | cachedStore = store; 34 | scope.children.set(callId, store); 35 | } else { 36 | // Please don't delete me during this render 37 | scope.scheduledDestroys.delete(callId); 38 | } 39 | 40 | return [cachedStore.value, cachedStore.setter]; 41 | } 42 | 43 | export function callOrderTrackedState( 44 | initialOrCompute: T | (() => T), 45 | ): readonly [T, Setter] { 46 | const rerender = CTX.rerender; 47 | const scope = CTX.parentScope; 48 | 49 | if (!scope) { 50 | throw new Error('Invalid state'); 51 | } 52 | 53 | const hookIdx = ++scope.lastHookIndex; 54 | let cachedStore = scope.hookStores[hookIdx] as StateStore | undefined; 55 | if (!cachedStore) { 56 | const store: StateStore = { 57 | value: 58 | typeof initialOrCompute === 'function' 59 | ? (initialOrCompute as () => T)() 60 | : initialOrCompute, 61 | setter(valueOrCompute) { 62 | if (typeof valueOrCompute === 'function') { 63 | store.value = (valueOrCompute as (prev: T) => T)(store.value); 64 | } else { 65 | store.value = valueOrCompute as T; 66 | } 67 | rerender?.(); 68 | }, 69 | }; 70 | cachedStore = store; 71 | scope.hookStores[hookIdx] = store; 72 | } 73 | 74 | return [cachedStore.value, cachedStore.setter]; 75 | } 76 | -------------------------------------------------------------------------------- /packages/react-nook/src/types.ts: -------------------------------------------------------------------------------- 1 | export type EffectCleanup = () => void; 2 | 3 | export type Setter = ((value: T) => void) & 4 | ((compute: (prev: T) => T) => void); 5 | 6 | export type AnyFn = (...args: never[]) => unknown; 7 | 8 | // biome-ignore lint/suspicious/noExplicitAny: contravariance 9 | export interface StateStore { 10 | value: T; 11 | setter: Setter; 12 | destroy?: undefined; 13 | } 14 | 15 | export interface EffectStore { 16 | deps: unknown[] | undefined; 17 | /** 18 | * To be called in a useEffect 19 | */ 20 | mount(): void; 21 | /** 22 | * To be called in the cleanup function of a useEffect 23 | */ 24 | unmount(): void; 25 | /** 26 | * To be called in a useEffect, before mounting dirty effects 27 | */ 28 | destroy(): void; 29 | } 30 | 31 | export interface CallbackStore { 32 | callback: T; 33 | deps: unknown[] | undefined; 34 | destroy?: undefined; 35 | } 36 | 37 | export interface MemoStore { 38 | value: T; 39 | deps: unknown[] | undefined; 40 | destroy?: undefined; 41 | } 42 | 43 | export type Store = StateStore | EffectStore | CallbackStore | MemoStore; 44 | 45 | export interface Scope { 46 | /** 47 | * How nested this scope is. Used only for debugging. 48 | */ 49 | depth: number; 50 | scopes: Map; 51 | children: Map; 52 | 53 | // Since we allow (some) standard React hooks to be used within nooks, we need order tracking for them 54 | lastHookIndex: number; 55 | hookStores: Store[]; 56 | 57 | /** 58 | * Reset before every render to be keys of `children`, then 59 | * each nook call takes itself out of this set. 60 | * 61 | * At the end of the scope, those that have not reported are 62 | * destroyed (cleaned-up). 63 | */ 64 | scheduledDestroys: Map; 65 | 66 | destroy(): void; 67 | } 68 | 69 | export interface RootScope extends Scope { 70 | /** 71 | * Set to false when an effect is considered dirty during 72 | * rendering, set to true when dirty effects were remounted. 73 | * 74 | * This can be used to track whether or not to reset `dirtyEffects` 75 | * at the beginning of a render. We don't want to reset dirtyEffects 76 | * if the effects that were marked last rendered didn't have a chance 77 | * to be flushed. We also don't want to reset `dirtyEffects` right after 78 | * mounting, as Strict Mode can force us to mount, unmount, then mount again. 79 | */ 80 | effectsFlushed: boolean; 81 | dirtyEffects: Set; 82 | /** 83 | * All reachable effects 84 | */ 85 | effects: Set; 86 | } 87 | -------------------------------------------------------------------------------- /packages/react-nook/tests/memo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | import { mountMemo, useNook } from '../src/index.ts'; 4 | 5 | describe('mountMemo nook', () => { 6 | it('should memoize values based on dependencies', () => { 7 | const factory = vi.fn(() => Math.random()); 8 | 9 | function TestComponent({ value }: { value: number }) { 10 | const memoized = useNook(() => { 11 | return mountMemo``(() => factory(), [value]); 12 | }); 13 | return
{memoized}
; 14 | } 15 | 16 | const { rerender } = render(); 17 | expect(factory).toHaveBeenCalledTimes(1); 18 | const firstResult = factory.mock.results[0]?.value; 19 | 20 | rerender(); 21 | expect(factory).toHaveBeenCalledTimes(1); 22 | 23 | rerender(); 24 | expect(factory).toHaveBeenCalledTimes(2); 25 | expect(factory.mock.results[1]?.value).not.toBe(firstResult); 26 | }); 27 | 28 | it('should recompute when dependencies change', () => { 29 | let computeCount = 0; 30 | const expensiveComputation = (input: number) => { 31 | computeCount++; 32 | return input * 2; 33 | }; 34 | 35 | function TestComponent({ input }: { input: number }) { 36 | const result = useNook(() => { 37 | return mountMemo``(() => expensiveComputation(input), [input]); 38 | }); 39 | return
{result}
; 40 | } 41 | 42 | const { rerender } = render(); 43 | expect(computeCount).toBe(1); 44 | 45 | rerender(); 46 | expect(computeCount).toBe(1); 47 | 48 | rerender(); 49 | expect(computeCount).toBe(2); 50 | }); 51 | 52 | it('should work without dependencies (always recompute)', () => { 53 | const factory = vi.fn(() => Math.random()); 54 | 55 | function TestComponent() { 56 | const memoized = useNook(() => { 57 | return mountMemo``(() => factory()); 58 | }); 59 | return
{memoized}
; 60 | } 61 | 62 | const { rerender } = render(); 63 | // React Strict Mode may cause double execution on first render 64 | const initialCallCount = factory.mock.calls.length; 65 | expect(initialCallCount).toBeGreaterThan(0); 66 | 67 | rerender(); 68 | // Should have been called again since no deps means always recompute 69 | expect(factory).toHaveBeenCalledTimes(initialCallCount + 1); 70 | }); 71 | 72 | it('should work with empty dependencies array', () => { 73 | const factory = vi.fn(() => Math.random()); 74 | 75 | function TestComponent() { 76 | const memoized = useNook(() => { 77 | return mountMemo``(() => factory(), []); 78 | }); 79 | return
{memoized}
; 80 | } 81 | 82 | const { rerender } = render(); 83 | expect(factory).toHaveBeenCalledTimes(1); 84 | 85 | rerender(); 86 | expect(factory).toHaveBeenCalledTimes(1); 87 | }); 88 | 89 | it('should work with multiple dependencies', () => { 90 | const factory = vi.fn((a: number, b: string) => `${a}-${b}`); 91 | 92 | function TestComponent({ num, str }: { num: number; str: string }) { 93 | const memoized = useNook(() => { 94 | return mountMemo``(() => factory(num, str), [num, str]); 95 | }); 96 | return
{memoized}
; 97 | } 98 | 99 | const { rerender } = render(); 100 | expect(factory).toHaveBeenCalledTimes(1); 101 | 102 | rerender(); 103 | expect(factory).toHaveBeenCalledTimes(1); 104 | 105 | rerender(); 106 | expect(factory).toHaveBeenCalledTimes(2); 107 | 108 | rerender(); 109 | expect(factory).toHaveBeenCalledTimes(3); 110 | }); 111 | 112 | it('should work within useNook', () => { 113 | const factory = vi.fn(() => 'computed'); 114 | 115 | function TestComponent({ value }: { value: number }) { 116 | const result = useNook(() => { 117 | return mountMemo``(() => factory(), [value]); 118 | }); 119 | 120 | return
{result}
; 121 | } 122 | 123 | const { rerender } = render(); 124 | expect(factory).toHaveBeenCalledTimes(1); 125 | 126 | rerender(); 127 | expect(factory).toHaveBeenCalledTimes(1); 128 | 129 | rerender(); 130 | expect(factory).toHaveBeenCalledTimes(2); 131 | }); 132 | 133 | it('should throw error when used outside nook context', () => { 134 | expect(() => mountMemo``(() => 'test', [])).toThrowErrorMatchingInlineSnapshot( 135 | `[Error: Invalid state]`, 136 | ); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /packages/react-nook/tests/nook.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { useEffect, useId } from 'react'; 3 | import { beforeEach, describe, expect, it } from 'vitest'; 4 | import { nook, useNook } from '../src/index.ts'; 5 | 6 | describe('using nooks', () => { 7 | it('should throw error top-level', () => { 8 | const fooNook = nook(() => 'foo' as const); 9 | 10 | expect(() => fooNook``()).toThrowErrorMatchingInlineSnapshot( 11 | `[Error: Nooks can only be called within other nooks, or via a \`useNook\` call]`, 12 | ); 13 | }); 14 | 15 | it('should throw error when used in a regular component', () => { 16 | const fooNook = nook(() => 'foo' as const); 17 | 18 | function Bar() { 19 | return

{fooNook``()}

; 20 | } 21 | 22 | expect(() => render()).toThrowErrorMatchingInlineSnapshot( 23 | `[Error: Nooks can only be called within other nooks, or via a \`useNook\` call]`, 24 | ); 25 | }); 26 | 27 | it('should throw when using a non-supported hook', () => { 28 | const $foo = nook(() => { 29 | // The `useId` hook is not supported at the time of writing this test 30 | return useId(); 31 | }); 32 | 33 | const Bar = nook(() => { 34 | const id = $foo``(); 35 | return

{id}

; 36 | }); 37 | 38 | expect(() => render()).toThrowErrorMatchingInlineSnapshot( 39 | `[Error: Cannot use 'useId' inside nooks yet. Please file an issue and tell us about your use-case.]`, 40 | ); 41 | }); 42 | }); 43 | 44 | describe('conditional based on prop', () => { 45 | let events: string[] = []; 46 | 47 | function useSideEffect() { 48 | useEffect(() => { 49 | events.push('mount'); 50 | return () => { 51 | events.push('unmount'); 52 | }; 53 | }); 54 | } 55 | 56 | const $sideEffect = nook(useSideEffect); 57 | 58 | function Foo(props: { active: boolean }) { 59 | useNook(() => { 60 | props.active && $sideEffect``(); 61 | }); 62 | 63 | return

Foo

; 64 | } 65 | 66 | beforeEach(() => { 67 | events = []; 68 | }); 69 | 70 | it('should unmount when unmounting the owner component (was always active)', () => { 71 | const result = render(); 72 | 73 | // Because of React Strict Mode, it does a quick mount&unmount before mounting for real 74 | expect(events).toMatchInlineSnapshot(` 75 | [ 76 | "mount", 77 | ] 78 | `); 79 | 80 | result.unmount(); 81 | 82 | // An additional unmount can be seen 83 | expect(events).toMatchInlineSnapshot(` 84 | [ 85 | "mount", 86 | ] 87 | `); 88 | }); 89 | 90 | it('should never mount when active=false', () => { 91 | const result = render(); 92 | 93 | expect(events).toMatchInlineSnapshot(`[]`); 94 | result.unmount(); 95 | expect(events).toMatchInlineSnapshot(`[]`); 96 | }); 97 | 98 | it('should mount when active becomes true, and unmount when it becomes false', () => { 99 | events = []; 100 | const result = render(); 101 | expect(events).toMatchInlineSnapshot(`[]`); 102 | 103 | // Interestingly, the React Strict Mode behavior applies even to effects that have 104 | // been mounted later than the component itself. 105 | events = []; 106 | result.rerender(); 107 | expect(events).toMatchInlineSnapshot(` 108 | [ 109 | "mount", 110 | ] 111 | `); 112 | 113 | // An additional unmount 114 | events = []; 115 | result.rerender(); 116 | expect(events).toMatchInlineSnapshot(` 117 | [ 118 | "unmount", 119 | ] 120 | `); 121 | 122 | // Interestingly, the React Strict Mode behavior applies even to effects that have 123 | // been mounted later than the component itself. 124 | events = []; 125 | result.rerender(); 126 | expect(events).toMatchInlineSnapshot(` 127 | [ 128 | "mount", 129 | ] 130 | `); 131 | 132 | events = []; 133 | result.unmount(); 134 | expect(events).toMatchInlineSnapshot(`[]`); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /packages/react-nook/tests/useEffect-behavior.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { StrictMode, useEffect, useState } from 'react'; 3 | import { beforeEach, describe, expect, it } from 'vitest'; 4 | import { nook, useNook } from '../src/index.ts'; 5 | 6 | /** 7 | * REACT STRICT MODE vs NON-STRICT MODE BEHAVIOR DIFFERENCES 8 | * 9 | * This test file documents and verifies the behavior differences between React's 10 | * Strict Mode and non-strict mode, particularly focusing on useEffect lifecycle. 11 | * 12 | * NOTE: These tests run in jsdom environment, which may exhibit different behavior 13 | * than browser environments regarding Strict Mode double invocation patterns. 14 | * 15 | * KEY DIFFERENCES: 16 | * 17 | * 1. STRICT MODE DOUBLE INVOCATION: 18 | * - In Strict Mode, React intentionally double-invokes effects during development 19 | * - Pattern: mount → unmount → mount (for initial render) 20 | * - This helps detect side effects that aren't properly cleaned up 21 | * - Only happens in development builds, not production 22 | * - NOTE: jsdom environment may not fully replicate browser Strict Mode behavior 23 | * 24 | * 2. NON-STRICT MODE BEHAVIOR: 25 | * - Effects run once per mount/dependency change 26 | * - Pattern: mount (for initial render) 27 | * - More predictable but may hide cleanup issues 28 | * 29 | * 3. CLEANUP VERIFICATION: 30 | * - Strict Mode's double invocation helps catch: 31 | * * Memory leaks from uncleaned event listeners 32 | * * Uncanceled network requests 33 | * * Uncleared timers/intervals 34 | * * Resource leaks 35 | * 36 | * 4. DEPENDENCY ARRAY BEHAVIOR: 37 | * - Both modes respect dependency arrays equally 38 | * - Strict Mode still double-invokes on dependency changes 39 | * 40 | * 5. NOOK-SPECIFIC BEHAVIOR: 41 | * - Nooks follow React's effect lifecycle 42 | * - Conditional nook mounting also exhibits Strict Mode behavior 43 | * - Nook cleanup happens during React's cleanup phase 44 | */ 45 | 46 | describe('useEffect behavior tracking with snapshots', () => { 47 | let events: string[] = []; 48 | 49 | // Helper to create trackable state 50 | function createTrackableState(name: string) { 51 | // biome-ignore lint/complexity/noUselessTypeConstraint: Useful to differentiate from JSX 52 | return (initial: T) => { 53 | return useState(() => { 54 | events.push(`${name}:init`); 55 | return initial; 56 | }); 57 | }; 58 | } 59 | 60 | // Helper to create trackable effects 61 | function createTrackableEffect(name: string) { 62 | return (deps?: unknown[]) => { 63 | useEffect(() => { 64 | events.push(`${name}:mount`); 65 | return () => { 66 | events.push(`${name}:cleanup`); 67 | }; 68 | // biome-ignore lint/correctness/useExhaustiveDependencies: it's a special helper, shh 69 | }, deps); 70 | }; 71 | } 72 | 73 | // Helper to create trackable nook effects 74 | function createTrackableNookEffect(name: string) { 75 | return nook((deps?: unknown[]) => { 76 | useEffect(() => { 77 | events.push(`nook-${name}:mount`); 78 | return () => { 79 | events.push(`nook-${name}:cleanup`); 80 | }; 81 | // biome-ignore lint/correctness/useExhaustiveDependencies: it's a special helper, shh 82 | }, deps); 83 | }); 84 | } 85 | 86 | beforeEach(() => { 87 | events = []; 88 | }); 89 | 90 | describe('Standard React useEffect behavior', () => { 91 | const useBasicEffect = createTrackableEffect('basic'); 92 | 93 | function BasicEffectComponent() { 94 | events.push('render'); 95 | useBasicEffect(); 96 | return
Basic Effect Component
; 97 | } 98 | 99 | it('should track initial mount behavior in Strict Mode', () => { 100 | /** 101 | * STRICT MODE EXPECTATION: 102 | * React Strict Mode will cause the following sequence: 103 | * 1. Component mounts → effect runs → "basic:mount" 104 | * 2. Component unmounts (Strict Mode) → cleanup runs → "basic:cleanup" 105 | * 3. Component mounts again → effect runs → "basic:mount" 106 | * 107 | * This double mount/unmount cycle helps detect side effects that 108 | * aren't properly cleaned up. 109 | */ 110 | const result = render( 111 | 112 | 113 | , 114 | ); 115 | 116 | // In Strict Mode, we expect the mount-unmount-mount pattern 117 | expect(events).toMatchInlineSnapshot(` 118 | [ 119 | "render", 120 | "render", 121 | "basic:mount", 122 | "basic:cleanup", 123 | "basic:mount", 124 | ] 125 | `); 126 | 127 | events = []; 128 | result.unmount(); 129 | 130 | // Final cleanup when component actually unmounts 131 | expect(events).toMatchInlineSnapshot(` 132 | [ 133 | "basic:cleanup", 134 | ] 135 | `); 136 | }); 137 | 138 | it('should track initial mount behavior without Strict Mode', () => { 139 | /** 140 | * NON-STRICT MODE EXPECTATION: 141 | * Without Strict Mode, the effect runs only once: 142 | * 1. Component mounts → effect runs → "basic:mount" 143 | * 144 | * No double invocation occurs, which may hide cleanup issues 145 | * but provides more predictable behavior. 146 | */ 147 | const result = render(); 148 | 149 | // Without Strict Mode, only single mount 150 | expect(events).toMatchInlineSnapshot(` 151 | [ 152 | "render", 153 | "basic:mount", 154 | ] 155 | `); 156 | 157 | events = []; 158 | result.unmount(); 159 | 160 | // Cleanup on actual unmount 161 | expect(events).toMatchInlineSnapshot(` 162 | [ 163 | "basic:cleanup", 164 | ] 165 | `); 166 | }); 167 | }); 168 | 169 | describe('useEffect with dependencies', () => { 170 | const useDependentEffect = createTrackableEffect('dependent'); 171 | 172 | function DependentEffectComponent({ value }: { value: number }) { 173 | events.push('render'); 174 | useDependentEffect([value]); 175 | return
Dependent Effect Component: {value}
; 176 | } 177 | 178 | it('should track dependency change behavior in Strict Mode', () => { 179 | /** 180 | * STRICT MODE WITH DEPENDENCIES: 181 | * Initial render follows the same mount-unmount-mount pattern. 182 | * When dependencies change, the effect cleanup and re-runs, but 183 | * Strict Mode also applies its double invocation to the new effect. 184 | */ 185 | const result = render( 186 | 187 | 188 | , 189 | ); 190 | 191 | // Initial Strict Mode pattern 192 | expect(events).toMatchInlineSnapshot(` 193 | [ 194 | "render", 195 | "render", 196 | "dependent:mount", 197 | "dependent:cleanup", 198 | "dependent:mount", 199 | ] 200 | `); 201 | 202 | events = []; 203 | result.rerender( 204 | 205 | 206 | , 207 | ); 208 | 209 | // Dependency change triggers single cleanup and remount 210 | expect(events).toMatchInlineSnapshot(` 211 | [ 212 | "render", 213 | "render", 214 | "dependent:cleanup", 215 | "dependent:mount", 216 | ] 217 | `); 218 | }); 219 | 220 | it('should track dependency change behavior without Strict Mode', () => { 221 | /** 222 | * NON-STRICT MODE WITH DEPENDENCIES: 223 | * Dependencies work normally - effect cleanup and re-runs once 224 | * when dependencies change, without double invocation. 225 | */ 226 | const result = render(); 227 | 228 | // Single mount without Strict Mode 229 | expect(events).toMatchInlineSnapshot(` 230 | [ 231 | "render", 232 | "dependent:mount", 233 | ] 234 | `); 235 | 236 | events = []; 237 | result.rerender(); 238 | 239 | // Single cleanup and remount on dependency change 240 | expect(events).toMatchInlineSnapshot(` 241 | [ 242 | "render", 243 | "dependent:cleanup", 244 | "dependent:mount", 245 | ] 246 | `); 247 | }); 248 | }); 249 | 250 | describe('Multiple effects interaction', () => { 251 | const useFirstEffect = createTrackableEffect('first'); 252 | const useThirdEffect = createTrackableEffect('third'); 253 | 254 | function MultiEffectComponent({ showSecond }: { showSecond: boolean }) { 255 | events.push('render'); 256 | useFirstEffect(); 257 | 258 | // Always call the hook, but conditionally execute the effect 259 | useEffect(() => { 260 | if (showSecond) { 261 | events.push('second:mount'); 262 | return () => { 263 | events.push('second:cleanup'); 264 | }; 265 | } 266 | }, [showSecond]); 267 | 268 | useThirdEffect([showSecond]); 269 | 270 | return
Multi Effect Component
; 271 | } 272 | 273 | it('should track multiple effects in Strict Mode', () => { 274 | /** 275 | * MULTIPLE EFFECTS IN STRICT MODE: 276 | * All effects follow the Strict Mode pattern independently. 277 | * Conditional effects also get the double invocation when they 278 | * first appear or when their dependencies change. 279 | */ 280 | const result = render( 281 | 282 | 283 | , 284 | ); 285 | 286 | // All effects get Strict Mode treatment 287 | // (all effects mount, then all effects unmount, then all effects mount again) 288 | expect(events).toMatchInlineSnapshot(` 289 | [ 290 | "render", 291 | "render", 292 | "first:mount", 293 | "third:mount", 294 | "first:cleanup", 295 | "third:cleanup", 296 | "first:mount", 297 | "third:mount", 298 | ] 299 | `); 300 | 301 | events = []; 302 | result.rerender( 303 | 304 | 305 | , 306 | ); 307 | 308 | // New conditional effect and dependency change both get Strict Mode treatment 309 | // (first cleanup all effects, then mount all effects) 310 | expect(events).toMatchInlineSnapshot(` 311 | [ 312 | "render", 313 | "render", 314 | "first:cleanup", 315 | "third:cleanup", 316 | "first:mount", 317 | "second:mount", 318 | "third:mount", 319 | ] 320 | `); 321 | }); 322 | }); 323 | 324 | describe('Nook effects behavior', () => { 325 | const nookBasicEffect = createTrackableNookEffect('basic'); 326 | const nookConditionalEffect = createTrackableNookEffect('conditional'); 327 | 328 | function NookEffectComponent(props: { showConditional: boolean }) { 329 | events.push('render'); 330 | useNook(() => { 331 | nookBasicEffect``(); 332 | if (props.showConditional) { 333 | nookConditionalEffect``(); 334 | } 335 | }); 336 | return
Nook Effect Component
; 337 | } 338 | 339 | it('should track nook effects in Strict Mode', () => { 340 | /** 341 | * NOOK EFFECTS IN STRICT MODE: 342 | * Nook effects follow React's useEffect lifecycle, so they also 343 | * exhibit Strict Mode behavior. This is important because it means 344 | * nook-based effects are subject to the same cleanup verification 345 | * that regular React effects receive. 346 | */ 347 | const result = render( 348 | 349 | 350 | , 351 | ); 352 | 353 | // Nook effects also get Strict Mode double invocation 354 | expect(events).toMatchInlineSnapshot(` 355 | [ 356 | "render", 357 | "render", 358 | "nook-basic:mount", 359 | "nook-basic:cleanup", 360 | "nook-basic:mount", 361 | ] 362 | `); 363 | 364 | events = []; 365 | result.rerender( 366 | 367 | 368 | , 369 | ); 370 | 371 | expect(events).toMatchInlineSnapshot(` 372 | [ 373 | "render", 374 | "render", 375 | "nook-basic:cleanup", 376 | "nook-basic:mount", 377 | "nook-conditional:mount", 378 | ] 379 | `); 380 | }); 381 | 382 | it('should track nook effects without Strict Mode', () => { 383 | /** 384 | * NOOK EFFECTS WITHOUT STRICT MODE: 385 | * Without Strict Mode, nook effects behave like regular effects - 386 | * they mount once and cleanup once, providing predictable behavior. 387 | */ 388 | const result = render(); 389 | 390 | // Single mount for nook effects 391 | expect(events).toMatchInlineSnapshot(` 392 | [ 393 | "render", 394 | "nook-basic:mount", 395 | ] 396 | `); 397 | 398 | events = []; 399 | result.rerender(); 400 | 401 | // Conditional nook mounts once 402 | expect(events).toMatchInlineSnapshot(` 403 | [ 404 | "render", 405 | "nook-basic:cleanup", 406 | "nook-basic:mount", 407 | "nook-conditional:mount", 408 | ] 409 | `); 410 | }); 411 | }); 412 | 413 | describe('State updates and effect interactions', () => { 414 | const useStateDependentEffect = createTrackableEffect('state-dependent'); 415 | const useMyState = createTrackableState('state'); 416 | 417 | function StateEffectComponent() { 418 | events.push('render'); 419 | const [count, setCount] = useMyState(0); 420 | 421 | // Effect that depends on state 422 | useStateDependentEffect([count]); 423 | 424 | // Effect that updates state (with proper cleanup) 425 | useEffect(() => { 426 | events.push('state-updater:mount'); 427 | const timer = setTimeout(() => { 428 | if (count < 2) { 429 | setCount((c) => c + 1); 430 | } 431 | }, 10); 432 | 433 | return () => { 434 | events.push('state-updater:cleanup'); 435 | clearTimeout(timer); 436 | }; 437 | }, [count, setCount]); 438 | 439 | return ( 440 |
441 | State Effect Component - Count: {count} 442 | 445 |
446 | ); 447 | } 448 | 449 | it('should track state-effect interactions in Strict Mode', async () => { 450 | /** 451 | * STATE-EFFECT INTERACTIONS IN STRICT MODE: 452 | * When effects update state, they can trigger re-renders and new 453 | * effect cycles. Strict Mode's double invocation applies to each 454 | * effect cycle, which can help catch issues with: 455 | * - Infinite update loops 456 | * - Race conditions 457 | * - Improper cleanup of async operations 458 | */ 459 | const result = render( 460 | 461 | 462 | , 463 | ); 464 | 465 | // Initial Strict Mode pattern for both effects 466 | expect(events).toMatchInlineSnapshot(` 467 | [ 468 | "render", 469 | "state:init", 470 | "state:init", 471 | "render", 472 | "state-dependent:mount", 473 | "state-updater:mount", 474 | "state-dependent:cleanup", 475 | "state-updater:cleanup", 476 | "state-dependent:mount", 477 | "state-updater:mount", 478 | ] 479 | `); 480 | events = []; 481 | 482 | // Wait for state update to trigger 483 | await new Promise((resolve) => setTimeout(resolve, 20)); 484 | 485 | // State change triggers new effect cycle with Strict Mode behavior 486 | expect(events).toMatchInlineSnapshot(` 487 | [ 488 | "render", 489 | "render", 490 | "state-dependent:cleanup", 491 | "state-updater:cleanup", 492 | "state-dependent:mount", 493 | "state-updater:mount", 494 | ] 495 | `); 496 | 497 | result.unmount(); 498 | }); 499 | }); 500 | }); 501 | -------------------------------------------------------------------------------- /packages/react-nook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | } 6 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | - packages/* 4 | 5 | catalog: 6 | arktype: ^2.1.20 7 | 8 | catalogs: 9 | build: 10 | tsdown: ^0.13.0 11 | frontend: 12 | react: ^19.1.0 13 | test: 14 | '@vitejs/plugin-react': ^4.4.1 15 | '@vitest/browser': ^3.1.3 16 | playwright: ^1.52.0 17 | vitest: ^3.2.4 18 | vitest-browser-react: ^0.1.1 19 | types: 20 | '@webgpu/types': ^0.1.64 21 | typescript: ^5.8.3 22 | 23 | onlyBuiltDependencies: 24 | - '@parcel/watcher' 25 | - '@tailwindcss/oxide' 26 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "${configDir}/dist", 4 | "baseUrl": "${configDir}", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "lib": ["ESNext", "DOM"], 8 | "emitDeclarationOnly": true, 9 | "target": "ESNext", 10 | "module": "ESNext", 11 | "strict": true, 12 | "allowImportingTsExtensions": true, 13 | "allowJs": true, 14 | "esModuleInterop": true, 15 | "moduleResolution": "bundler", 16 | "noUncheckedIndexedAccess": true, 17 | "exactOptionalPropertyTypes": true, 18 | "skipLibCheck": true, 19 | "types": ["@vitest/browser/matchers"] 20 | }, 21 | "exclude": ["${configDir}/dist", "${configDir}/node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | browser: { 8 | enabled: false, 9 | provider: 'playwright', 10 | instances: [{ browser: 'chromium' }], 11 | }, 12 | environment: 'jsdom', 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------