Repository: brandonmcconnell/render-hooks
Branch: main
Commit: 3df0b16ff1fd
Files: 26
Total size: 101.7 KB
Directory structure:
gitextract_ii3d9lst/
├── .github/
│ └── workflows/
│ └── chromatic.yml
├── .gitignore
├── .npmignore
├── .storybook/
│ ├── Theme.js
│ ├── main.ts
│ ├── manager.ts
│ ├── preview.ts
│ └── storybook.css
├── LICENSE
├── README.md
├── package.json
├── src/
│ ├── index.test.tsx
│ ├── index.tsx
│ └── stories/
│ ├── 00-QuickStart.stories.tsx
│ ├── 01-BuiltInHooks.stories.tsx
│ ├── 02-CustomHooks.stories.tsx
│ ├── 03-NestingRenderHooks.stories.tsx
│ ├── Header.tsx
│ ├── Page.tsx
│ ├── assets/
│ │ └── avif-test-image.avif
│ ├── button.css
│ ├── header.css
│ └── page.css
├── tsconfig.json
├── vite-env.d.ts
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/chromatic.yml
================================================
name: "Chromatic"
on: push
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22.12.0
- name: Install dependencies
# ⚠️ See your package manager's documentation for the correct command to install dependencies in a CI environment.
run: npm ci
- name: Run Chromatic
uses: chromaui/action@latest
env:
# Expose the secret so Vite/Storybook can replace `import.meta.env.STORYBOOK_CODESANDBOX_TOKEN`
STORYBOOK_CODESANDBOX_TOKEN: ${{ secrets.STORYBOOK_CODESANDBOX_TOKEN }}
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
================================================
FILE: .gitignore
================================================
# Dependencies
node_modules/
# Build output
dist/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Vitest cache and coverage
.vitest_cache/
coverage/
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarnclean
# parcel-bundler cache files
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build output
.nuxt
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#unzipping-automatic-routing-in-pages-now-supports-dynamic-routing
# public
# vuepress build output
.vuepress/dist
# Docusaurus build output
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Gatsby Supabase cache files
.gatsby-supabase-cache
*storybook.log
storybook-static/
================================================
FILE: .npmignore
================================================
# Source files
src/
# Config files
.eslintignore
.eslintrc.js
.prettierrc.js
.prettierignore
# Tests
__tests__/
src/**/*.test.ts
src/**/*.test.tsx
vitest.config.ts
vitest.setup.ts
coverage/
# Storybook
.storybook/
stories/
storybook-static/
# Other
.DS_Store
*.log
# Git
.git/
.gitignore
.env
# Other
================================================
FILE: .storybook/Theme.js
================================================
import { create } from '@storybook/theming';
export default create({
base: 'dark',
brandTitle: 'RenderHooks',
brandUrl: 'https://github.com/brandonmcconnell/render-hooks',
brandImage: 'https://github.com/brandonmcconnell/render-hooks/blob/main/.github/render-hooks-logo_full.png?raw=true',
// brandTarget: '_self',
});
================================================
FILE: .storybook/main.ts
================================================
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@codesandbox/storybook-addon"
],
"framework": {
"name": "@storybook/react-vite",
"options": {}
},
"typescript": {
"reactDocgen": "react-docgen-typescript",
"reactDocgenTypescriptOptions": {
// You might need to specify compilerOptions here if they differ from your main tsconfig
// For example, to ensure JSX is handled correctly:
// compilerOptions: {
// jsx: "react-jsx", // or "react"
// },
// Filter props from node_modules (optional, but good practice)
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
// If your components are not directly in `src` or you have a specific tsconfig for app compilation:
// tsconfigPath: "./tsconfig.app.json", // Or your relevant tsconfig
}
}
};
export default config;
================================================
FILE: .storybook/manager.ts
================================================
import { addons } from '@storybook/manager-api';
import theme from './Theme';
import './storybook.css';
addons.setConfig({
theme,
});
================================================
FILE: .storybook/preview.ts
================================================
import type { Preview } from '@storybook/react'
import theme from './Theme'
import './storybook.css';
const preview: Preview = {
parameters: {
docs: {
theme,
},
codesandbox: {
/**
* @required
* Workspace API key from codesandbox.io/t/permissions.
* This sandbox is created inside the given workspace
* and can be shared with team members.
*/
// @ts-expect-error (2339) Property 'env' does not exist on type 'ImportMeta'.
apiToken: import.meta.env.STORYBOOK_CODESANDBOX_TOKEN,
/**
* @required
* Dependencies list to be installed in the sandbox.
*
* @note You cannot use local modules or packages since
* this story runs in an isolated environment (sandbox)
* inside CodeSandbox. As such, the sandbox doesn't have
* access to your file system.
*
* Example:
*/
dependencies: {
"render-hooks": "latest",
"react": "latest",
"react-dom": "latest",
},
/**
* @required
* CodeSandbox will try to import all components by default from
* the given package, in case `mapComponent` property is not provided.
*
* This property is useful when your components imports are predictable
* and come from a single package and entry point.
*/
fallbackImport: "render-hooks",
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
================================================
FILE: .storybook/storybook.css
================================================
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
body {
font-family: "Inter", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
.sidebar-header a[title] img[class] {
width: 220px !important;
max-width: 220px !important;
height: auto !important;
}
[data-story-block] > [data-name] {
padding: 24px;
}
.docs-story {
color: #eee;
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Brandon McConnell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
RenderHooks (render-hooks) Use hooks inline in React/JSX
RenderHooks lets you place hooks right next to the markup that needs them—no wrapper components, no breaking the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks), and zero boilerplate, even when you supply your own custom hooks.
---
- [📖 How it works](#-how-it-works)
- [✨ Features](#-features)
- [🚀 Install](#-install)
- [⚡ Quick start](#-quick-start)
- [🧩 API](#-api)
- [📚 Examples by hook](#-examples-by-hook)
- [`useState` (React ≥ 16.8)](#usestatereact--168)
- [`useReducer` (React ≥ 16.8)](#usereducerreact--168)
- [`useCallback` (React ≥ 16.8)](#usecallbackreact--168)
- [`useContext` (React ≥ 16.8)](#usecontextreact--168)
- [`useMemo` (React ≥ 16.8)](#usememoreact--168)
- [`useEffect` (React ≥ 16.8)](#useeffectreact--168)
- [`useLayoutEffect` (React ≥ 16.8)](#uselayouteffectreact--168)
- [`useImperativeHandle` (React ≥ 16.8)](#useimperativehandlereact--168)
- [`useRef` (React ≥ 16.8)](#userefreact--168)
- [`useInsertionEffect` (React ≥ 18)](#useinsertioneffectreact--18)
- [`useId` (React ≥ 18)](#useidreact--18)
- [`useSyncExternalStore` (React ≥ 18)](#usesyncexternalstorereact--18)
- [`useDeferredValue` (React ≥ 18)](#usedeferredvaluereact--18)
- [`useTransition` (React ≥ 18)](#usetransitionreact--18)
- [`useActionState` (React ≥ 19, experimental in 18)](#useactionstatereact--19-experimental-in-18)
- [`useFormStatus` (React-DOM ≥ 19)](#useformstatusreact-dom--19)
- [`use` (awaitable hook, React ≥ 19)](#useawaitable-hook-react--19)
- [🛠 Custom hooks](#-custom-hooks)
- [🧱 Nesting hooks](#-nesting-hooks)
- [🤝 Collaboration](#-collaboration)
- [How to contribute](#how-to-contribute)
---
## 📖 How it works
1. At runtime RenderHooks scans the installed `react` and `react-dom`
modules and wraps every export whose name starts with **`use`**.
2. A TypeScript mapped type reproduces *exactly* the same keys from the typings,
so autocompletion never lies.
3. The callback you give to `` (commonly aliased, e.g. `<$>`) is executed during that same render
pass, keeping the Rules of Hooks intact.
4. Custom hooks are merged in once—stable reference, fully typed.
---
## ✨ Features
| ✔︎ | Description |
|----|-------------|
| **One element** | `<$>` merges every `use*` hook exposed by the consumer's version of **`react` + `react-dom`** into a single helpers object. |
| **Version-adaptive** | Only the hooks that exist in *your* React build appear. Upgrade React → new hooks show up automatically. |
| **Custom-hook friendly** | Pass an object of your own hooks once—full IntelliSense inside the render callback. |
| **100 % type-safe** | No `any`, no `unknown`. Generic signatures flow through the helpers object. |
| **Tiny runtime** | Just an object merge—`<$>` renders nothing to the DOM. |
---
## 🚀 Install
```bash
npm install render-hooks # or yarn / pnpm / bun
```
RenderHooks lists **`react`** and **`react-dom`** as peer dependencies, so it
always tracks *your* versions.
---
## ⚡ Quick start
```tsx
import $ from 'render-hooks';
export function Counter() {
return (
<$>
{({ useState }) => {
const [n, set] = useState(0);
return ;
}}
$>
);
}
```
The hook runs during the same render, so the Rules of Hooks are upheld.
---
## 🧩 API
| Prop | Type | Description |
|-------------|----------------------------------------------------|-------------|
| `hooks` | `Record unknown>` | (optional) custom hooks to expose. |
| `children` | `(helpers) ⇒ ReactNode` | Render callback receiving **all** built-in hooks available in your React version **plus** the custom hooks you supplied. |
---
## 📚 Examples by hook
Below is a **minimal, practical snippet for every built-in hook**.
Each header lists the **minimum React (or React-DOM) version** required—if your
project uses an older version, that hook simply won't appear in the helpers
object.
> All snippets assume
> `import $ from 'render-hooks';`
---
### `useState` (React ≥ 16.8)
```tsx
export function UseStateExample() {
return (
<$>
{({ useState }) => {
const [value, set] = useState('');
return set(e.target.value)} />;
}}
$>
);
}
```
---
### `useReducer` (React ≥ 16.8)
```tsx
export function UseReducerExample() {
return (
<$>
{({ useReducer }) => {
const [count, dispatch] = useReducer(
(s: number, a: 'inc' | 'dec') => (a === 'inc' ? s + 1 : s - 1),
0,
);
return (
<>
{count}
>
);
}}
$>
);
}
```
---
### `useCallback` (React ≥ 16.8)
```tsx
export function UseCallbackExample() {
return (
<$>
{({ useState, useCallback }) => {
const [txt, setTxt] = useState('');
const onChange = useCallback(
(e: React.ChangeEvent) => setTxt(e.target.value),
[],
);
return ;
}}
$>
);
}
```
---
### `useContext` (React ≥ 16.8)
```tsx
const ThemeCtx = React.createContext<'light' | 'dark'>('light');
export function UseContextExample() {
return (
<$>
{({ useContext }) =>
}
$>
);
}
```
---
## 🛠 Custom hooks
Inject any custom hooks once via the `hooks` prop:
```tsx
import $ from 'render-hooks';
import { useToggle, useDebounce } from './myHooks';
export function Example() {
return (
<$ hooks={{ useToggle, useDebounce }}>
{({ useToggle, useDebounce }) => {
const [open, toggle] = useToggle(false);
const dOpen = useDebounce(open, 250);
return (
<>
debounced: {dOpen.toString()}
>
);
}}
$>
);
}
```
---
## 🧱 Nesting hooks
You can nest `RenderHooks` (`$`) as deeply as you need. Each instance provides its own fresh set of hooks, scoped to its render callback. This is particularly useful for managing item-specific state within loops, where you'd otherwise need to create separate components.
Here's an example where RenderHooks is used to manage state for both levels of a nested list directly within the `.map()` callbacks, and a child can affect a parent RenderHook's state:
```tsx
import React from 'react'; // Needed for useState, useTransition in this example
import $ from 'render-hooks';
type Category = {
id: number;
name: string;
posts: { id: number; title: string }[];
};
const data: Category[] = [
{
id: 1,
name: 'Tech',
posts: [{ id: 11, title: 'Next-gen CSS' }],
},
{
id: 2,
name: 'Life',
posts: [
{ id: 21, title: 'Minimalism' },
{ id: 22, title: 'Travel hacks' },
],
},
];
export function NestedExample() {
return (
{cat.posts.map((post) => (
/* ───── 2️⃣ Inner RenderHooks per post row ───── */
<$ key={post.id}>
{({ useState: useItemState }) => {
const [liked, setItemLiked] = useItemState(false);
const toggleLike = () => {
setItemLiked((prev) => {
// 🔄 Update outer «likes» using startTransition from the parent RenderHooks
const next = !prev;
startTransition(() => {
setLikes((c) => c + (next ? 1 : -1));
});
return next;
});
};
return (
{post.title}{' '}
);
}}
$>
))}
)}
);
}}
$>
))}
);
}
```
In this example:
- The main `NestedExample` component does not use RenderHooks directly.
- The **first `.map()`** iterates through `data`. Inside this map, `<$>` is used to give each `category` its own states: `expanded` and `likes`. It also gets `useTransition` to acquire `startTransition`.
- The **second, inner `.map()`** iterates through `cat.posts`. Inside *this* map, another, nested `<$>` is used to give each `post` its own independent `liked` state.
- Crucially, when a post's `toggleLike` function is called, it updates its local `liked` state and then calls `startTransition` (obtained from the parent category's RenderHooks scope) to wrap the update to the parent's `likes` state.
This demonstrates not only nesting for independent state but also how functions and transition control from a parent RenderHooks instance can be utilized by children that also use RenderHooks, facilitating robust cross-scope communication.
> [!IMPORTANT]
> **Note on `startTransition`**: Using `startTransition` here is important. When an interaction (like clicking "Like") in a nested `RenderHooks` instance needs to update state managed by a parent `RenderHooks` instance, React might issue a warning about "updating one component while rendering another" if the update is synchronous. Wrapping the parent's state update in `startTransition` signals to React that this update can be deferred, preventing the warning and ensuring smoother UI updates. This is a general React pattern applicable when updates across component boundaries (or deeply nested state updates) might occur.
---
## 🤝 Collaboration
RenderHooks is a community-driven project. Every idea, issue, and pull request helps it grow and improve.
Whether you're fixing a typo or implementing a brand-new feature, **you're warmly welcome here!** ✨
### How to contribute
1. ⭐️ **Star the repo** – it helps others discover the project and shows your support.
2. 🐛 **Report bugs / request features** – open an issue and describe the problem or idea. Reproduction steps or code snippets are golden.
3. 📚 **Improve the docs** – spot a typo, unclear wording, or missing example? Submit a quick PR.
4. 👩💻 **Send code changes** – bug fixes, performance tweaks, new examples, or custom hooks… big or small, they're all appreciated. If you're unsure, open a draft PR and we'll figure it out together.
5. 💬 **Join the conversation** – comment on issues & PRs, share how you're using RenderHooks, or ask questions. First-time contributors are encouraged to jump in!
6. 📣 **Share it** - if you love it, please share it! I want to grow this tool into something that makes all of our day-to-day lives a bit easier, so no gate-keeping.
If this would be your **first open-source contribution**, don't hesitate to ask for guidance—I'll happily walk you through the process.
Thank you for making RenderHooks better for everyone! 🙏
================================================
FILE: package.json
================================================
{
"name": "render-hooks",
"version": "0.2.0",
"description": "Inline render-block-stable React hooks",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"default": "./dist/index.mjs"
}
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"build": "tsdown src/index.tsx --out-dir dist --dts --format cjs,esm",
"dev": "tsdown src/index.tsx --out-dir dist --dts --watch --format cjs,esm",
"prepublishOnly": "npm run test && npm run build",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"chromatic": "npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/brandonmcconnell/render-hooks.git"
},
"keywords": [
"react",
"render",
"inline",
"hooks"
],
"author": "Brandon McConnell",
"license": "MIT",
"bugs": {
"url": "https://github.com/brandonmcconnell/render-hooks/issues"
},
"homepage": "https://github.com/brandonmcconnell/render-hooks#readme",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.6",
"@codesandbox/storybook-addon": "^0.2.2",
"@storybook/addon-essentials": "^8.6.13",
"@storybook/addon-onboarding": "^8.6.13",
"@storybook/blocks": "^8.6.13",
"@storybook/manager-api": "^8.6.13",
"@storybook/react": "^8.6.13",
"@storybook/react-vite": "^8.6.13",
"@storybook/test": "^8.6.13",
"@storybook/theming": "^8.6.13",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.15.18",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-storybook": "^0.12.0",
"happy-dom": "^14.12.3",
"jsdom": "^26.1.0",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"storybook": "^8.6.13",
"tsdown": "^0.11.9",
"typescript": "^5.8.3",
"vitest": "^2.0.4"
},
"eslintConfig": {
"extends": [
"plugin:storybook/recommended"
]
}
}
================================================
FILE: src/index.test.tsx
================================================
/** @vitest-environment jsdom */
import React from 'react';
import { render, screen, fireEvent, waitFor, act as rtlAct, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import $ from './index'; // Assuming RenderHooks is the default export from src/index.tsx
import { useFormStatus as reactDom_useFormStatus } from 'react-dom'; // Import for useFormStatus test
import { vi, describe, it, expect } from 'vitest';
describe('RenderHooks Component', () => {
describe('Built-in Hooks Examples', () => {
it('useState example works', () => {
const UseStateExample = () => (
<$>
{({ useState }) => {
const [value, set] = useState('');
return set(e.target.value)} />;
}}
$>
);
render();
const inputElement = screen.getByLabelText('value-input') as HTMLInputElement;
expect(inputElement.value).toBe('');
fireEvent.change(inputElement, { target: { value: 'test' } });
expect(inputElement.value).toBe('test');
});
it('useReducer example works', () => {
const UseReducerExample = () => (
<$>
{({ useReducer }) => {
const [count, dispatch] = useReducer(
(s: number, a: 'inc' | 'dec') => (a === 'inc' ? s + 1 : s - 1),
0,
);
return (
<>
{count}
>
);
}}
$>
);
render();
const countSpan = screen.getByTestId('count-span');
const incButton = screen.getByText('+');
const decButton = screen.getByText('-');
expect(countSpan).toHaveTextContent('0');
fireEvent.click(incButton);
expect(countSpan).toHaveTextContent('1');
fireEvent.click(decButton);
fireEvent.click(decButton);
expect(countSpan).toHaveTextContent('-1');
});
it('useCallback example works', () => {
const mockFn = vi.fn();
const UseCallbackExample = () => (
<$>
{({ useState, useCallback }) => {
const [txt, setTxt] = useState('');
const onChange = useCallback(
(e: React.ChangeEvent) => {
setTxt(e.target.value);
mockFn(e.target.value);
},
[],
);
return ;
}}
$>
);
render();
const inputElement = screen.getByLabelText('callback-input');
fireEvent.change(inputElement, { target: { value: 'callback test' } });
expect(mockFn).toHaveBeenCalledWith('callback test');
});
it('useContext example works', () => {
const ThemeCtx = React.createContext<'light' | 'dark'>('light');
const UseContextExample = () => (
<$>
{({ useContext }) =>
>
);
}}
$>
);
render();
const input = screen.getByLabelText('deferred-input');
const output = screen.getByTestId('deferred-output');
expect(output).toHaveTextContent('deferred:');
rtlAct(() => {
fireEvent.change(input, { target: { value: 'hello' } });
});
// Allow deferred value to update
rtlAct(() => {
vi.runAllTimers();
});
vi.advanceTimersByTime(0); // Flush tasks
expect(output).toHaveTextContent('deferred: hello');
vi.useRealTimers();
}, 5000); // Test-specific timeout
it('useTransition example works', async () => {
vi.useFakeTimers();
const UseTransitionExample = () => (
<$>
{({ useState, useTransition }) => {
const [list, setList] = useState([]);
const [pending, start] = useTransition();
const filter = (e: React.ChangeEvent) => {
const q = e.target.value;
start(() => {
// Simulate async work for the transition with a timer
setTimeout(() => {
const fullList = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
setList(fullList.filter((x) => x.includes(q)));
}, 10);
});
};
return (
<>
{/* We will not assert the pending state directly due to timing complexities with fake timers */}
{/* {pending &&
updating…
} */}
{list.length} items
{list.map(item =>
{item}
)}
>
);
}}
$>
);
render();
const input = screen.getByLabelText('transition-input');
const listLength = screen.getByTestId('transition-list-length');
expect(listLength).toHaveTextContent('0 items');
rtlAct(() => {
fireEvent.change(input, { target: { value: 'Item 1' } });
});
// Removed: Check for pending state, as it's too transient with fake timers here.
// Complete the transition work by running all timers
rtlAct(() => {
vi.runAllTimers(); // This executes the setTimeout(..., 10) inside startTransition
});
// Flush any pending macrotasks from React updates
vi.advanceTimersByTime(0);
// Assertions for the final state
expect(screen.queryByText('updating…')).not.toBeInTheDocument(); // Should be gone now
expect(listLength).toHaveTextContent('1 items');
expect(screen.getByText('Item 1')).toBeInTheDocument();
vi.useRealTimers();
}, 10000);
it('useActionState example works', async () => {
vi.useFakeTimers();
const mockAction = vi.fn(async (_prev: string | null, data: FormData) => {
await new Promise((r) => setTimeout(r, 50));
return `Said: ${data.get('text') as string}`;
});
const UseActionStateExample = () => (
<$>
{({ useActionState }) => {
const [msg, submit, pending] = useActionState(mockAction, null);
return (
);
}}
$>
);
render();
const input = screen.getByLabelText('action-state-input');
const button = screen.getByText('Send');
rtlAct(() => {
fireEvent.change(input, { target: { value: 'Hello Action' } });
fireEvent.click(button);
});
// Assert pending state immediately after action is dispatched
expect(screen.getByText('Submitting...')).toBeInTheDocument();
expect(button).toBeDisabled();
// Wait for the action to complete and UI to update
// This act block covers the resolution of timers and the promise from mockAction,
// and the subsequent state updates in useActionState.
await rtlAct(async () => {
vi.runAllTimers(); // Resolve the setTimeout within mockAction
await Promise.resolve(); // Ensure promise from mockAction (if any) resolves and is processed
});
// Assert final state: React should have updated after the act block
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
// Final check for mock calls after waitFor confirms UI is stable
expect(mockAction).toHaveBeenCalledTimes(1);
vi.useRealTimers();
}, 5000);
it('useFormStatus example works (within a form component)', async () => {
vi.useFakeTimers(); // Good practice, though mockFormAction is manually resolved
let formActionResolver: () => void = () => {}; // Initialize to satisfy TS
const mockFormAction = vi.fn(async () => {
await new Promise(resolve => { formActionResolver = resolve; });
});
const SubmitButton = () => {
const { pending } = reactDom_useFormStatus();
return ;
};
const StatusDisplay = () => {
const { pending } = reactDom_useFormStatus();
return pending ?
Form is pending...
:
Form is idle.
;
};
const UseFormStatusExampleForm = () => {
const [done, setDone] = React.useState(false);
const formActionAttr = async (payload: FormData) => {
await mockFormAction();
setDone(true);
};
return (
<$>
{() => (
)}
$>
);
};
render();
const saveButton = screen.getByText('Save');
expect(screen.getByText('Form is idle.')).toBeInTheDocument();
rtlAct(() => {
fireEvent.click(saveButton);
});
expect(mockFormAction).toHaveBeenCalledTimes(1);
// Assert pending state for SubmitButton and StatusDisplay
expect(screen.getByText('Saving…')).toBeInTheDocument();
expect(screen.getByText('Form is pending...')).toBeInTheDocument();
expect(saveButton).toBeDisabled();
// Resolve the form action and wait for state updates
await rtlAct(async () => {
formActionResolver(); // Resolve the mockFormAction's promise
await Promise.resolve(); // Ensure promise propagation and related state updates are processed
});
vi.advanceTimersByTime(0); // Flush any final React updates if necessary
// Assert final state
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByText('Form is idle.')).toBeInTheDocument();
expect(screen.getByTestId('form-done-msg')).toHaveTextContent('saved!');
expect(saveButton).not.toBeDisabled();
vi.useRealTimers();
}, 5000);
it("'use' (awaitable hook) example works", async () => {
vi.useFakeTimers();
const fetchQuote = () => new Promise((r) => setTimeout(() => r('"Ship early, ship often."' ), 50));
const fetchQuotePromise = fetchQuote(); // Get the promise instance beforehand
// Ensure the promise is resolved before rendering the component that uses it.
// Use act to be safe, though direct await might also work if no React updates are expected here.
await rtlAct(async () => {
vi.runAllTimers(); // Resolve the setTimeout in fetchQuote
await fetchQuotePromise; // Explicitly wait for the promise to settle
});
const UseAwaitExample = () => (
Loading quote...}>
<$>
{({ use }) => {
const quote = use(fetchQuotePromise);
return
{quote}
;
}}
$>
);
// Wrap render in act to handle the synchronous resolution of the pre-resolved promise by the 'use' hook
await rtlAct(async () => {
render();
// Since the promise is pre-resolved, React should synchronously render the result.
// We might need a microtask tick for React to fully process if there are internal updates.
await Promise.resolve();
});
// With a pre-resolved promise and render in act, the content should be immediately available
expect(screen.getByText('"Ship early, ship often."' )).toBeInTheDocument();
// And the fallback should not have been rendered (or be gone)
expect(screen.queryByText('Loading quote...')).not.toBeInTheDocument();
vi.useRealTimers();
}, 5000);
});
describe('Custom Hooks Example', () => {
it('custom hooks can be provided and used', async () => {
vi.useFakeTimers(); // Added for debounce
const useToggle = (initialValue = false): [boolean, () => void] => {
const [value, setValue] = React.useState(initialValue);
const toggle = React.useCallback(() => setValue((v) => !v), []);
return [value, toggle];
};
const useDebounce = (value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
const customHooks = { useToggle, useDebounce };
const CustomHooksExample = () => (
<$ hooks={customHooks}>
{({ useToggle, useDebounce }) => {
const [open, toggle] = useToggle(false);
const dOpen = useDebounce(open, 50);
return (
<>
open: {open.toString()}
debounced: {dOpen.toString()}
>
);
}}
$>
);
render();
const toggleButton = screen.getByText('toggle');
const openState = screen.getByTestId('custom-open-state');
const debouncedState = screen.getByTestId('custom-debounced-state');
expect(openState).toHaveTextContent('open: false');
expect(debouncedState).toHaveTextContent('debounced: false');
fireEvent.click(toggleButton);
expect(openState).toHaveTextContent('open: true');
expect(debouncedState).toHaveTextContent('debounced: false');
// Wait for debounce to complete
rtlAct(() => {
vi.runAllTimers(); // Process setTimeout in useDebounce
});
vi.advanceTimersByTime(0); // Ensure React re-renders and microtasks are flushed
// Now assert the debounced state
expect(debouncedState).toHaveTextContent('debounced: true');
vi.useRealTimers(); // Added for debounce
}, 5000); // Added test-specific timeout
});
});
// --- New Test Suite for NestedImpactfulExample ---
type Category = {
id: number;
name: string;
posts: { id: number; title: string }[];
};
const data: Category[] = [
{
id: 1,
name: 'Tech',
posts: [{ id: 11, title: 'Next-gen CSS' }],
},
{
id: 2,
name: 'Life',
posts: [
{ id: 21, title: 'Minimalism' },
{ id: 22, title: 'Travel hacks' },
],
},
];
// This is the component from README.md
const NestedImpactfulExample = () => {
return (