├── .node-version ├── LICENSE ├── README.md ├── CHANGELOG.md ├── packages ├── docs │ ├── src │ │ ├── styles │ │ │ └── main.css │ │ ├── pages │ │ │ ├── _app.ts │ │ │ ├── is-safari.mdx │ │ │ ├── fetch-jsonp.mdx │ │ │ ├── use-is-online.mdx │ │ │ ├── open-in-new-tab.mdx │ │ │ ├── use-page-visibility.mdx │ │ │ ├── create-fixed-array.mdx │ │ │ ├── use-next-pathname.mdx │ │ │ ├── current-year.mdx │ │ │ ├── noop.mdx │ │ │ ├── use-retimer.mdx │ │ │ ├── typescript-happy-forward-ref.mdx │ │ │ ├── use-react-router-is-match.mdx │ │ │ ├── use-isomorphic-layout-effect.mdx │ │ │ ├── request-idle-callback.mdx │ │ │ ├── getting-started.mdx │ │ │ ├── use-clipboard.mdx │ │ │ ├── invariant-nullthrow.mdx │ │ │ ├── use-uncontrolled.mdx │ │ │ ├── use-composition-input.mdx │ │ │ ├── use-singleton.mdx │ │ │ ├── rem.mdx │ │ │ ├── use-typescript-happy-callback.mdx │ │ │ ├── use-fast-click.mdx │ │ │ ├── where-is.mdx │ │ │ ├── use-next-link.mdx │ │ │ ├── use-media-query.mdx │ │ │ ├── use-error-boundary.mdx │ │ │ ├── use-session-storage.mdx │ │ │ ├── use-abortable-effect.mdx │ │ │ ├── use-is-client.mdx │ │ │ ├── use-intersection.mdx │ │ │ ├── index.mdx │ │ │ ├── _meta.json │ │ │ ├── create-session-storage-state.mdx │ │ │ ├── compose-context-provider.mdx │ │ │ ├── no-ssr.mdx │ │ │ ├── create-local-storage-state.mdx │ │ │ ├── use-component-will-receive-update.mdx │ │ │ ├── use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.mdx │ │ │ ├── use-local-storage.mdx │ │ │ ├── use-debounced-value.mdx │ │ │ ├── use-debounced-state.mdx │ │ │ ├── use-react-router-enable-concurrent-navigation.mdx │ │ │ ├── context-state.mdx │ │ │ └── best-practice.mdx │ │ ├── libs │ │ │ └── sizes.ts │ │ ├── components │ │ │ ├── home.tsx │ │ │ └── export-meta-info.tsx │ │ └── hooks │ │ │ └── use-latest-exports-sizes.ts │ ├── tailwind.config.js │ ├── postcss.config.js │ ├── next.config.js │ ├── package.json │ ├── tsconfig.json │ └── theme.config.tsx └── foxact │ ├── src │ ├── create-context-state │ │ └── index.ts │ ├── noop │ │ └── index.ts │ ├── invariant │ │ └── index.ts │ ├── nullthrow │ │ └── index.ts │ ├── use-is-client │ │ └── index.ts │ ├── use-retimer │ │ └── index.ts │ ├── use-typescript-happy-callback │ │ └── index.ts │ ├── use-local-storage │ │ └── index.ts │ ├── open-new-tab │ │ └── index.ts │ ├── use-next-pathname │ │ └── index.ts │ ├── use-session-storage │ │ └── index.ts │ ├── is-safari │ │ └── index.ts │ ├── use-isomorphic-layout-effect │ │ └── index.ts │ ├── typescript-happy-forward-ref │ │ └── index.ts │ ├── create-local-storage-state │ │ └── index.ts │ ├── use-component-will-receive-update │ │ └── index.ts │ ├── create-session-storage-state │ │ └── index.ts │ ├── use-error-boundary │ │ └── index.ts │ ├── use-abortable-effect │ │ └── index.ts │ ├── use-singleton │ │ └── index.ts │ ├── use-array │ │ └── index.ts │ ├── use-is-online │ │ └── index.ts │ ├── compose-context-provider │ │ └── index.ts │ ├── use-page-visibility │ │ └── index.ts │ ├── use-map │ │ └── index.ts │ ├── use-set │ │ └── index.ts │ ├── request-idle-callback │ │ └── index.ts │ ├── types │ │ └── index.ts │ ├── use-debounced-state │ │ └── index.ts │ ├── current-year │ │ └── index.tsx │ ├── use-fast-click │ │ └── index.ts │ ├── create-fixed-array │ │ └── index.ts │ ├── use-debounced-value │ │ └── index.ts │ ├── use │ │ └── index.ts │ ├── use-uncontrolled │ │ └── index.ts │ ├── use-react-router-is-match │ │ └── index.ts │ ├── no-ssr │ │ └── index.ts │ ├── email-protection │ │ └── index.ts │ ├── rem │ │ └── index.ts │ ├── use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired │ │ └── index.ts │ ├── use-react-router-enable-concurrent-navigation │ │ └── index.ts │ ├── create-storage-state-factory │ │ └── index.ts │ ├── use-media-query │ │ └── index.ts │ ├── fetch-jsonp │ │ └── index.ts │ ├── context-state │ │ └── index.tsx │ ├── use-composition-input │ │ └── index.ts │ ├── use-clipboard │ │ └── index.ts │ ├── use-url-hash-state │ │ └── index.ts │ ├── use-intersection │ │ └── index.ts │ ├── create-storage-hook │ │ └── index.ts │ └── use-next-link │ │ └── index.ts │ ├── tsconfig.json │ ├── tools │ ├── get-entries.ts │ └── postbuild.ts │ ├── LICENSE │ ├── package.json │ ├── README.md │ └── rollup.config.ts ├── pnpm-workspace.yaml ├── turbo.json ├── tsconfig.base.json ├── .gitignore ├── eslint.config.js ├── .github └── workflows │ ├── pull-request.yml │ ├── docs.yml │ └── publish.yml └── package.json /.node-version: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ./packages/foxact/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./packages/foxact/README.md -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ./packages/foxact/CHANGELOG.md -------------------------------------------------------------------------------- /packages/docs/src/styles/main.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/foxact' 3 | - 'packages/docs' 4 | -------------------------------------------------------------------------------- /packages/foxact/src/create-context-state/index.ts: -------------------------------------------------------------------------------- 1 | /** @see https://foxact.skk.moe/context-state */ 2 | export { createContextState } from '../context-state'; 3 | -------------------------------------------------------------------------------- /packages/foxact/src/noop/index.ts: -------------------------------------------------------------------------------- 1 | export interface Noop { 2 | (...args: any[]): any 3 | } 4 | 5 | /** @see https://foxact.skk.moe/noop */ 6 | export const noop: Noop = () => { /* noop */ }; 7 | -------------------------------------------------------------------------------- /packages/docs/src/pages/_app.ts: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app'; 2 | import '../styles/main.css'; 3 | import { createElement } from 'react'; 4 | 5 | export default function Nextra({ Component, pageProps }: AppProps) { 6 | return createElement(Component, pageProps); 7 | } 8 | -------------------------------------------------------------------------------- /packages/docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './src/**/*.{js,ts,jsx,tsx,mdx,md}', 6 | './theme.config.tsx' 7 | ], 8 | theme: { 9 | extend: {} 10 | }, 11 | plugins: [] 12 | }; 13 | -------------------------------------------------------------------------------- /packages/foxact/src/invariant/index.ts: -------------------------------------------------------------------------------- 1 | /** @see https://foxact.skk.moe/invariant-nullthrow */ 2 | export function invariant(value: T, message = '[foxact/invariant] "value" is null or undefined'): asserts value is NonNullable { 3 | if (value === null || value === undefined) { 4 | throw new TypeError(message); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/foxact/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | } 8 | }, 9 | "include": [ 10 | "./**/*.ts", 11 | "./**/*.tsx", 12 | "./rollup.config.ts", 13 | "./tools/**/*.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/docs/src/libs/sizes.ts: -------------------------------------------------------------------------------- 1 | const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; 2 | export function humanReadableSize(bytes: number) { 3 | let results = bytes; 4 | let i = 0; 5 | while (results >= 1024 && i < units.length) { 6 | results /= 1024; 7 | ++i; 8 | } 9 | return `${i === 0 ? results : results.toFixed(2)} ${units[i]}`; 10 | } 11 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": [".next/**", "!.next/cache/**", "out", "dist"] 7 | }, 8 | "lint": { 9 | "dependsOn": ["^lint"] 10 | }, 11 | "dev": { 12 | "persistent": true, 13 | "cache": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/foxact/src/nullthrow/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: https://github.com/microsoft/TypeScript/issues/40562 2 | 3 | /** @see https://foxact.skk.moe/invariant-nullthrow */ 4 | export function nullthrow(value: T, message = '[foxact/invariant] "value" is null or undefined'): NonNullable { 5 | if (value === null || value === undefined) { 6 | throw new TypeError(message); 7 | } 8 | return value; 9 | } 10 | -------------------------------------------------------------------------------- /packages/docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | [require.resolve('next/dist/compiled/postcss-flexbugs-fixes')]: {}, 5 | [require.resolve('next/dist/compiled/postcss-preset-env')]: { 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3, 10 | features: { 11 | 'custom-properties': false 12 | } 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /packages/docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require('nextra')({ 2 | theme: 'nextra-theme-docs', 3 | themeConfig: './theme.config.tsx' 4 | }); 5 | 6 | module.exports = withNextra(/** @type {import('next').NextConfig} */({ 7 | output: 'export', 8 | reactStrictMode: true, 9 | trailingSlash: true, 10 | images: { 11 | unoptimized: true 12 | }, 13 | typescript: { 14 | ignoreBuildErrors: true 15 | } 16 | })); 17 | -------------------------------------------------------------------------------- /packages/foxact/src/use-is-client/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | import { noop } from '../noop'; 4 | import { useSyncExternalStore } from 'react'; 5 | 6 | function trueFn() { 7 | return true; 8 | } 9 | function falseFn() { 10 | return false; 11 | } 12 | 13 | /** @see https://foxact.skk.moe/use-is-client */ 14 | export function useIsClient() { 15 | return useSyncExternalStore( 16 | noop, 17 | trueFn, 18 | falseFn 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/docs/src/pages/is-safari.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: isSafari 3 | --- 4 | 5 | # isSafari 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Since Safari browser is a known modern Internet Explorer, you should just check if the browser is Safari and apply the necessary polyfills or special logics. 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { isSafari } from 'foxact/is-safari'; 17 | 18 | isSafari(); // true or false 19 | ``` 20 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["DOM", "ES2018", "ESNext.WeakRef"], 5 | "jsx": "react-jsx", 6 | "module": "preserve", 7 | "moduleResolution": "bundler", 8 | "resolveJsonModule": true, 9 | "declaration": true, 10 | "outDir": "./dist", 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "strictNullChecks": true, 15 | "skipLibCheck": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/foxact/src/use-retimer/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | // useRef is React Client Component only 3 | 4 | import { useCallback, useRef } from 'react'; 5 | 6 | /** @see https://foxact.skk.moe/use-retimer */ 7 | export function useRetimer() { 8 | const timerIdRef = useRef(); 9 | 10 | return useCallback((timerId?: number) => { 11 | if (typeof timerIdRef.current === 'number') { 12 | clearTimeout(timerIdRef.current); 13 | } 14 | timerIdRef.current = timerId; 15 | }, []); 16 | } 17 | -------------------------------------------------------------------------------- /packages/foxact/src/use-typescript-happy-callback/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback as useCallbackFromReact } from 'react'; 2 | 3 | /** @see https://foxact.skk.moe/use-typescript-happy-callback */ 4 | export const useTypeScriptHappyCallback: ( 5 | fn: (...args: Args) => R, 6 | deps: React.DependencyList 7 | ) => (...args: Args) => R = useCallbackFromReact; 8 | 9 | /** @see https://foxact.skk.moe/use-typescript-happy-callback */ 10 | export const useCallback = useTypeScriptHappyCallback; 11 | -------------------------------------------------------------------------------- /packages/docs/src/pages/fetch-jsonp.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: fetchJsonp 3 | --- 4 | 5 | # fetchJsonp 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Resolve JSONP request with a promise. 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { fetchJsonp } from 'foxact/fetch-jsonp'; 17 | 18 | const data = await fetchJsonp( 19 | // the `getUrl` function that passes the callback name 20 | (callbackName) => 'https://api.example.com/data?callback=' + callbackName 21 | ); 22 | ``` 23 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-is-online.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useIsOnline 3 | --- 4 | 5 | # useIsOnline 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Simple React Hook for checking if you're connected to the internet. It uses `useSyncExternalStore` under the hood to support React 18 concurrent rendering and server-side rendering. 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { useIsOnline } from 'foxact/use-is-online'; 17 | 18 | useIsOnline(); // Returns a boolean value 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/docs/src/pages/open-in-new-tab.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Open in New Tab 3 | --- 4 | 5 | # Open in New Tab 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Open a URL in a new tab. Use `window.open` on the Safari browser (since nowadays Safari is known as a modern Internet Explorer) and `document.createElement('a')` on other browsers. 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { openInNewTab } from 'foxact/open-in-new-tab'; 17 | 18 | openInNewTab('https://skk.moe'); 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/foxact/src/use-local-storage/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { createStorage } from '../create-storage-hook'; 3 | 4 | export type { 5 | Serializer, Deserializer, 6 | UseStorageRawOption as UseLocalStorageRawOption, 7 | UseStorageParserOption as UseLocalStorageParserOption 8 | } from '../create-storage-hook'; 9 | 10 | const { useStorage: useLocalStorage, useSetStorage: useSetLocalStorage } = createStorage('localStorage'); 11 | 12 | /** @see https://foxact.skk.moe/use-local-storage */ 13 | export { useLocalStorage, useSetLocalStorage }; 14 | -------------------------------------------------------------------------------- /packages/foxact/src/open-new-tab/index.ts: -------------------------------------------------------------------------------- 1 | import { isSafari } from '../is-safari'; 2 | 3 | export function openInNewTab(url: string) { 4 | if (typeof window === 'undefined') { 5 | return; 6 | } 7 | 8 | if (isSafari()) { 9 | window.open(url, '_blank'); 10 | return; 11 | } 12 | 13 | const a = document.createElement('a'); 14 | a.href = url; 15 | a.target = '_blank'; 16 | a.rel = 'noopener noreferrer'; 17 | document.body.appendChild(a); 18 | a.click(); 19 | 20 | Promise.resolve().finally(() => { 21 | a.remove(); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /packages/foxact/src/use-next-pathname/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | import { useRouter } from 'next/router.js'; 4 | import { useMemo } from 'react'; 5 | 6 | /** @see https://foxact.skk.moe/use-next-pathname */ 7 | export function useNextPathname(ensureTrailingSlash = false) { 8 | const { asPath } = useRouter(); 9 | return useMemo(() => { 10 | const path = asPath.split(/[#?]/)[0]; 11 | if (ensureTrailingSlash) { 12 | return path.endsWith('/') ? path : `${path}/`; 13 | } 14 | return path; 15 | }, [ensureTrailingSlash, asPath]); 16 | } 17 | -------------------------------------------------------------------------------- /packages/foxact/src/use-session-storage/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { createStorage } from '../create-storage-hook'; 3 | 4 | export type { 5 | Serializer, Deserializer, 6 | UseStorageRawOption as UseSessionStorageRawOption, 7 | UseStorageParserOption as UseSessionStorageParserOption 8 | } from '../create-storage-hook'; 9 | 10 | const { useStorage: useSessionStorage, useSetStorage: useSetSessionStorage } = createStorage('sessionStorage'); 11 | 12 | /** @see https://foxact.skk.moe/use-session-storage */ 13 | export { useSessionStorage, useSetSessionStorage }; 14 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-page-visibility.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: usePageVisibility 3 | --- 4 | 5 | # usePageVisibility 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Simple React Hook for checking if the app is in the foreground or background. It uses `useSyncExternalStore` under the hood to support React 18 concurrent rendering and server-side rendering. 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { usePageVisibility } from 'foxact/use-page-visibility'; 17 | 18 | usePageVisibility(); // Returns a boolean value 19 | ``` 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | /.pnp 4 | .pnp.js 5 | .pnp.cjs 6 | .pnp.loader.mjs 7 | yarn.lock 8 | /.yarn 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | .next/ 15 | out/ 16 | next-env.d.ts 17 | 18 | # production 19 | /build 20 | dist 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | 41 | # turborepo 42 | .turbo 43 | 44 | # misc 45 | .DS_Store 46 | -------------------------------------------------------------------------------- /packages/foxact/src/is-safari/index.ts: -------------------------------------------------------------------------------- 1 | export function isSafari() { 2 | if (typeof window === 'undefined') { 3 | return false; 4 | } 5 | if (typeof navigator === 'undefined') { 6 | return false; 7 | } 8 | if (typeof navigator.userAgent !== 'string') { 9 | return false; 10 | } 11 | if (/version\/[\d._].*?safari/i.test(navigator.userAgent)) { 12 | return true; 13 | } 14 | // eslint-disable-next-line sukka/prefer-single-boolean-return -- cleaner code 15 | if (/mobile safari [\d._]+/i.test(navigator.userAgent)) { 16 | return true; 17 | } 18 | return false; 19 | } 20 | -------------------------------------------------------------------------------- /packages/foxact/src/use-isomorphic-layout-effect/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-restricted-imports -- the implementation of useIsomorphicLayoutEffect 4 | import { useEffect, useLayoutEffect as useLayoutEffectFromReact } from 'react'; 5 | 6 | /** @see https://foxact.skk.moe/use-isomorphic-layout-effect */ 7 | export const useIsomorphicLayoutEffect = typeof window === 'undefined' 8 | ? useEffect 9 | : useLayoutEffectFromReact; 10 | 11 | /** @see https://foxact.skk.moe/use-isomorphic-layout-effect */ 12 | export const useLayoutEffect = useIsomorphicLayoutEffect; 13 | -------------------------------------------------------------------------------- /packages/foxact/src/typescript-happy-forward-ref/index.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | export interface TypeScriptHappyForwardRef { 4 | ( 5 | render: (props: P, ref: React.ForwardedRef) => React.ReactNode | null 6 | ): (props: P & React.RefAttributes) => React.ReactNode | null 7 | } 8 | 9 | /** @see https://foxact.skk.moe/typescript-happy-forward-ref */ 10 | export const typeScriptHappyForwardRef = forwardRef as TypeScriptHappyForwardRef; 11 | /** @see https://foxact.skk.moe/typescript-happy-forward-ref */ 12 | export const typescriptHappyForwardRef = forwardRef as TypeScriptHappyForwardRef; 13 | -------------------------------------------------------------------------------- /packages/foxact/src/create-local-storage-state/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | import { createStorageStateFactory } from '../create-storage-state-factory'; 4 | 5 | /** 6 | * @see https://foxact.skk.moe/create-local-storage-state 7 | * 8 | * @example 9 | * ```ts 10 | * const [useOpenState, useOpen] = createLocalStorageState( 11 | * 'open', // storage key 12 | * false, // server default value 13 | * { raw: false } // options 14 | * ); 15 | * 16 | * const [open, setOpen] = useOpenState(); 17 | * const open = useOpen(); 18 | * ``` 19 | */ 20 | export const createLocalStorageState = createStorageStateFactory('localStorage'); 21 | -------------------------------------------------------------------------------- /packages/foxact/src/use-component-will-receive-update/index.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | /** 4 | * @see {https://foxact.skk.moe/use-component-will-receive-update} 5 | */ 6 | export function useComponentWillReceiveUpdate(callback: () => void, deps: readonly unknown[]) { 7 | deps = [...deps]; 8 | const [prev, setPrev] = useState(deps); 9 | let changed = deps.length !== prev.length; 10 | for (let i = 0; i < deps.length; i += 1) { 11 | if (changed) break; 12 | if (prev[i] !== deps[i]) changed = true; 13 | } 14 | if (changed) { 15 | setPrev(deps); 16 | callback(); 17 | } 18 | 19 | return changed; 20 | } 21 | -------------------------------------------------------------------------------- /packages/foxact/tools/get-entries.ts: -------------------------------------------------------------------------------- 1 | import { fdir as Fdir } from 'fdir'; 2 | import path from 'node:path'; 3 | 4 | const rootDir = process.cwd(); 5 | const srcDir = path.join(rootDir, 'src'); 6 | 7 | export async function getEntries() { 8 | const files = await new Fdir() 9 | .withRelativePaths() 10 | .crawl(srcDir) 11 | .withPromise(); 12 | 13 | return files.reduce>((prev, cur) => { 14 | const entryName = path.basename(cur, path.extname(cur)); 15 | const dir = path.dirname(cur); 16 | 17 | if (entryName === 'index') { 18 | prev[dir] = path.join('src', cur); 19 | } 20 | return prev; 21 | }, {}); 22 | } 23 | -------------------------------------------------------------------------------- /packages/foxact/src/create-session-storage-state/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | import { createStorageStateFactory } from '../create-storage-state-factory'; 4 | 5 | /** 6 | * @see https://foxact.skk.moe/create-session-storage-state 7 | * 8 | * @example 9 | * ```ts 10 | * ```ts 11 | * const [useOpenState, useOpen] = createSessionStorageState( 12 | * 'open', // storage key 13 | * false, // server default value 14 | * { raw: false } // options 15 | * ); 16 | * 17 | * const [open, setOpen] = useOpenState(); 18 | * const open = useOpen(); 19 | * ``` 20 | */ 21 | export const createSessionStorageState = createStorageStateFactory('sessionStorage'); 22 | -------------------------------------------------------------------------------- /packages/foxact/src/use-error-boundary/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | // useState is only available in the React Client Components. 3 | 4 | import { useState } from 'react'; 5 | 6 | type ErrorLike = Error | undefined | null | boolean; 7 | 8 | function isTruthy(value: ErrorLike): value is Error { 9 | return value !== false && value != null; 10 | } 11 | 12 | /** @see https://foxact.skk.moe/use-error-boundary */ 13 | export function useErrorBoundary(givenError: ErrorLike = false) { 14 | const [error, setError] = useState(false); 15 | 16 | if (isTruthy(givenError)) throw givenError; 17 | if (isTruthy(error)) throw error; 18 | return setError; 19 | } 20 | -------------------------------------------------------------------------------- /packages/foxact/src/use-abortable-effect/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useEffect as useEffectFromReact } from 'react'; 3 | import type { EffectCallback, DependencyList } from 'react'; 4 | 5 | /** @see https://foxact.skk.moe/use-abortable-effect */ 6 | export function useAbortableEffect(callback: (signal: AbortSignal) => ReturnType, deps: DependencyList) { 7 | useEffectFromReact(() => { 8 | const controller = new AbortController(); 9 | const signal = controller.signal; 10 | const f = callback(signal); 11 | return () => { 12 | controller.abort(); 13 | f?.(); 14 | }; 15 | }, deps); 16 | } 17 | export const useEffect = useAbortableEffect; 18 | -------------------------------------------------------------------------------- /packages/foxact/src/use-singleton/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | import { useRef } from 'react'; 4 | 5 | export interface SingletonRefObject { 6 | readonly current: T 7 | } 8 | 9 | /** @see https://foxact.skk.moe/use-singleton */ 10 | export function useSingleton(initializor: () => T): SingletonRefObject { 11 | const r = useRef(null); 12 | if (r.current == null) { 13 | r.current = initializor(); 14 | } 15 | 16 | // We are using singleton approach here, to prevent repeated initialization. 17 | // The value will only be written by the hook during the first render and it 18 | // should not be written by anyone else anymore 19 | // @ts-expect-error -- see above 20 | return r; 21 | } 22 | -------------------------------------------------------------------------------- /packages/foxact/src/use-array/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useCallback, useState } from 'react'; 3 | 4 | export function useArray(initialState: T[] | (() => T[]) = () => []) { 5 | const [array, setArray] = useState(initialState); 6 | 7 | const add = useCallback((v: T) => setArray((prevArray) => prevArray.concat(v)), []); 8 | const reset = useCallback(() => setArray([]), []); 9 | 10 | const removeByIndex = useCallback((index: number) => setArray((prevArray) => { 11 | if (index > -1) { 12 | const copy = prevArray.slice(); 13 | copy.splice(index, 1); 14 | return copy; 15 | } 16 | return prevArray; 17 | }), []); 18 | 19 | return [array, add, reset, removeByIndex] as const; 20 | } 21 | -------------------------------------------------------------------------------- /packages/docs/src/pages/create-fixed-array.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: createFixedArray 3 | --- 4 | 5 | # createFixedArray 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Create shared fixed-size read-only arrays. 12 | 13 | ## Usage 14 | 15 | Below is an example of creating a loading skeleton screen with 10 skeleton items: 16 | 17 | ```tsx copy 18 | import { memo } from 'react'; 19 | import { createFixedArray } from 'foxact/create-fixed-array'; 20 | 21 | const PostsLoading = () => { 22 | return ( 23 | 24 | {createFixedArray(10).map(i => ( 25 | 26 | ))} 27 | 28 | ); 29 | }; 30 | 31 | export default memo(PostsLoading); 32 | ``` 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('eslint-config-sukka').sukka({ 4 | ignores: { 5 | customGlobs: ['dist/**/*', 'docs/**/*', ...require('eslint-config-sukka').constants.GLOB_EXCLUDE] 6 | }, 7 | react: { 8 | nextjs: false 9 | } 10 | }, { 11 | rules: { 12 | 'paths/alias': 'off', 13 | '@eslint-react/no-use-context': 'off', 14 | '@eslint-react/no-context-provider': 'off' 15 | } 16 | }, { 17 | // next.js/nextra naming convention 18 | files: [ 19 | '**/app/**/_*.cjs', 20 | String.raw`**/app/**/\[*.?([cm])[j]s?(x)`, 21 | '**/pages/_app.?([cm])[jt]s?(x)', 22 | '**/pages/document.?([cm])[jt]s?(x)' 23 | ], 24 | rules: { 25 | '@eslint-react/naming-convention/filename': 'off' 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foxact-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "next dev", 9 | "build": "next build" 10 | }, 11 | "author": "Sukka ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "foxact": "link:../foxact/dist", 15 | "next": "^15.5.6", 16 | "nextra": "^2.13.4", 17 | "nextra-theme-docs": "^2.13.4", 18 | "react": "18.3.1", 19 | "react-dom": "18.3.1", 20 | "swr": "^2.3.7", 21 | "ufo": "^1.6.1" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "18.3.27", 25 | "@types/react-dom": "18.3.7", 26 | "eslint-plugin-mdx": "^3.6.2", 27 | "tailwindcss": "^3.4.18" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/foxact/src/use-is-online/index.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSyncExternalStore } from 'react'; 4 | 5 | function subscribe(onStoreChange: () => void): () => void { 6 | window.addEventListener('online', onStoreChange); 7 | window.addEventListener('offline', onStoreChange); 8 | return () => { 9 | window.removeEventListener('online', onStoreChange); 10 | window.removeEventListener('offline', onStoreChange); 11 | }; 12 | } 13 | 14 | function getSnapshot() { 15 | if (typeof window === 'undefined') { 16 | return false; 17 | } 18 | 19 | return navigator.onLine; 20 | } 21 | 22 | /** @see https://foxact.skk.moe/use-is-online */ 23 | export function useIsOnline() { 24 | return useSyncExternalStore( 25 | subscribe, 26 | getSnapshot, 27 | getSnapshot 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "ES2017", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "jsx": "preserve", 11 | "module": "preserve", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "allowJs": true, 15 | "noEmit": true, 16 | "isolatedModules": true, 17 | "esModuleInterop": true, 18 | "strict": false, 19 | "strictNullChecks": true, 20 | "skipLibCheck": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | ".next/types/**/*.ts", 30 | "**/*.ts", 31 | "**/*.tsx" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /packages/foxact/src/compose-context-provider/index.ts: -------------------------------------------------------------------------------- 1 | import { cloneElement, memo } from 'react'; 2 | import type { Foxact } from '../types'; 3 | 4 | export interface ContextComposeProviderProps extends Foxact.PropsWithChildren { 5 | // eslint-disable-next-line @typescript-eslint/no-restricted-types -- cloneElement 6 | contexts: React.ReactElement[] 7 | } 8 | 9 | /** @see https://foxact.skk.moe/compose-context-provider */ 10 | export const ComposeContextProvider = memo(({ 11 | contexts, 12 | children 13 | }: ContextComposeProviderProps) => contexts.reduceRight( 14 | // eslint-disable-next-line @eslint-react/no-clone-element -- Composing elements based on props 15 | (children: React.ReactNode, parent) => cloneElement( 16 | parent, 17 | { children } 18 | ), 19 | children 20 | )); 21 | -------------------------------------------------------------------------------- /packages/foxact/src/use-page-visibility/index.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSyncExternalStore } from 'react'; 4 | 5 | const handlePageVisibilityChange: Parameters[0] = (onChange) => { 6 | document.addEventListener('visibilitychange', onChange); 7 | return () => { 8 | document.removeEventListener('visibilitychange', onChange); 9 | }; 10 | }; 11 | 12 | const getSnapshot: Parameters[1] = () => { 13 | if (typeof document === 'undefined') { 14 | return false; 15 | } 16 | 17 | return !document.hidden; 18 | }; 19 | 20 | /** @see https://foxact.skk.moe/use-page-visibility */ 21 | export function usePageVisibility() { 22 | return useSyncExternalStore( 23 | handlePageVisibilityChange, 24 | getSnapshot, 25 | getSnapshot 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-next-pathname.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useNextPathname (Next.js Pages Router) 3 | --- 4 | 5 | # useNextPathname (Next.js Pages Router) 6 | 7 | Read the current URL's pathname when using Next.js Pages Router. 8 | 9 | import { Callout } from 'nextra/components' 10 | 11 | 12 | If you are using Next.js App Router, please use `usePathname()` from `next/navigation` instead. 13 | 14 | 15 | ## Usage 16 | 17 | ```tsx copy 18 | import { useNextPathname } from 'foxact/use-next-pathname'; 19 | // If you are using Next.js App Router, use this instead: 20 | // import { usePathname } from 'next/navigation'; 21 | 22 | export default function ExampleClientComponent() { 23 | const pathname = useNextPathname() 24 | return

Current pathname: {pathname}

25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Check 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - '**/*.md' 6 | - LICENSE 7 | - '**/*.gitignore' 8 | - .editorconfig 9 | - docs/** 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | - uses: pnpm/action-setup@v4 16 | name: Install pnpm 17 | with: 18 | run_install: false 19 | - name: Setup node 20 | uses: actions/setup-node@v6 21 | with: 22 | node-version-file: '.node-version' 23 | check-latest: true 24 | cache: 'pnpm' 25 | - name: Install dependencies 26 | run: pnpm install 27 | # - name: Lint 28 | # run: pnpm run lint 29 | - name: Build 30 | run: pnpm run build 31 | -------------------------------------------------------------------------------- /packages/foxact/src/use-map/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useCallback, useState } from 'react'; 3 | 4 | export function useMap(initialState: Map | (() => Map) = () => new Map()) { 5 | const [map, setMap] = useState>(initialState); 6 | 7 | const add = useCallback((k: K, v: T) => setMap((prevMap) => { 8 | prevMap.set(k, v); 9 | return new Map(prevMap); 10 | }), []); 11 | 12 | const remove = useCallback((k: K) => setMap((prevMap) => { 13 | if (!prevMap.has(k)) { 14 | return prevMap; 15 | } 16 | prevMap.delete(k); 17 | return new Map(prevMap); 18 | }), []); 19 | 20 | const reset = useCallback(() => setMap(new Map()), []); 21 | const setAll = useCallback((m: Map) => setMap(m), []); 22 | 23 | return [map, add, remove, reset, setAll] as const; 24 | } 25 | -------------------------------------------------------------------------------- /packages/foxact/src/use-set/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useCallback, useState } from 'react'; 3 | 4 | export function useSet(initialState: Set | (() => Set) = () => new Set()) { 5 | const [set, setSet] = useState(initialState); 6 | 7 | const add = useCallback((item: T) => setSet((prevSet) => { 8 | if (prevSet.has(item)) { 9 | return prevSet; 10 | } 11 | return new Set([...prevSet, item]); 12 | }), []); 13 | 14 | const remove = useCallback((item: T) => setSet((prevSet) => { 15 | if (!prevSet.has(item)) { 16 | return prevSet; 17 | } 18 | prevSet.delete(item); 19 | return new Set(prevSet); 20 | }), []); 21 | 22 | const reset = useCallback(() => setSet(new Set()), []); 23 | const setAll = useCallback((s: Set) => setSet(s), []); 24 | 25 | return [set, add, remove, reset, setAll] as const; 26 | } 27 | -------------------------------------------------------------------------------- /packages/foxact/src/request-idle-callback/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition -- polyfill */ 2 | 3 | /** @see https://foxact.skk.moe/request-idle-callback */ 4 | export const requestIdleCallback = ( 5 | typeof self !== 'undefined' 6 | && self.requestIdleCallback 7 | && self.requestIdleCallback.bind(self) 8 | ) || function (cb: IdleRequestCallback): number { 9 | const start = Date.now(); 10 | return self.setTimeout(() => { 11 | cb({ 12 | didTimeout: false, 13 | timeRemaining() { 14 | return Math.max(0, 50 - (Date.now() - start)); 15 | } 16 | }); 17 | }, 1); 18 | }; 19 | 20 | /** @see https://foxact.skk.moe/request-idle-callback */ 21 | export const cancelIdleCallback = ( 22 | typeof self !== 'undefined' 23 | && self.cancelIdleCallback 24 | && self.cancelIdleCallback.bind(self) 25 | ) || function (id: number) { 26 | return clearTimeout(id); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/docs/src/pages/current-year.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: CurrentYear 3 | --- 4 | 5 | # CurrentYear 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Render a `` element with the current year, works well with React Server Components, React Server-side Rendering, no "mismatch" errors. 12 | 13 | ## Usage 14 | 15 | During the server-side rendering, the `defaultYear` prop is required and its value will be emitted to the output HTML. On the client-side, the `defaultYear` prop will be used for hydration. After hydration is done, the `` component will re-render with the `new Date().getFullYear()`. 16 | 17 | ```tsx filename="src/footer.tsx" copy 18 | export default function Footer() { 19 | return ( 20 |
21 |

22 | © 2018 - All rights reserved. 23 |

24 |
25 | ) 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'docs/**/*' 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | pages: write 14 | id-token: write 15 | environment: 16 | name: github-pages 17 | url: ${{ steps.deployment.outputs.page_url }} 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | run_install: false 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version-file: '.node-version' 26 | check-latest: true 27 | cache: 'pnpm' 28 | - run: pnpm install 29 | - run: pnpm run build 30 | - uses: actions/upload-pages-artifact@v3 31 | with: 32 | path: packages/docs/out 33 | - uses: actions/deploy-pages@v4 34 | id: deployment 35 | -------------------------------------------------------------------------------- /packages/docs/src/components/home.tsx: -------------------------------------------------------------------------------- 1 | // import { Callout } from 'nextra-theme-docs'; 2 | // import { Bleed } from 'nextra-theme-docs'; 3 | 4 | export default function HomePage() { 5 | return ( 6 | <> 7 |
8 | { } 9 | foxact Logo 10 |

foxact

11 |
12 | 13 |
14 |

15 | React Hooks/Utils done right. 16 |

17 |

18 | For Browser, SSR, and React Server Components. 19 |

20 |
21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/foxact/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export namespace Foxact { 2 | export type PropsWithRef

= Readonly>; 3 | export type PropsWithoutRef

= Readonly>; 4 | export type PropsWithChildren

= Readonly>; 5 | export type ComponentProps> = Readonly>; 6 | export type CustomComponentProps = Readonly>; 7 | export type ComponentPropsWithRef = Readonly>; 8 | export type CustomComponentPropsWithRef = Readonly>; 9 | export type ComponentPropsWithoutRef = Readonly>; 10 | export type CustomComponentPropsWithoutRef = Readonly>; 11 | } 12 | -------------------------------------------------------------------------------- /packages/docs/src/pages/noop.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Noop 3 | --- 4 | 5 | # Noop 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | This method simply returns `undefined`. It is introduced to be used as a default value for [Context State](/context-state)'s dispatch context. 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { noop } from 'foxact/noop'; 17 | import { createContext } from 'react'; 18 | 19 | // noop is used as a default value for dispatch context 20 | const DispatchFoxtailContext = createContext>>(noop); 21 | const FoxtailContext = createContext('sukka'); 22 | 23 | export default function App() { 24 | const [foxtail, setFoxtail] = useState('sukka'); 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-retimer.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useRetimer 3 | --- 4 | 5 | # useRetimer 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Use [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) without worrying about memory leaks and race conditions. 12 | 13 | ## Usage 14 | 15 | Let's say you are building a click-to-close button that should disappear 3 seconds after the latest click: 16 | 17 | ```tsx copy 18 | import { useCallback, useState } from 'react'; 19 | import { useRetimer } from 'foxact/use-retimer'; 20 | 21 | const Demo = () => { 22 | const [open, setOpen] = useState(true); 23 | const retimer = useRetimer(); 24 | 25 | const handleClick = useCallback(() => { 26 | retimer(setTimeout(() => setOpen(false), 3000)); 27 | }, [retimer]); // retimer is memoized so you can include it in the dependency array 28 | 29 | return ( 30 |

31 | {open && } 32 |
33 | ); 34 | }; 35 | ``` 36 | -------------------------------------------------------------------------------- /packages/foxact/src/use-debounced-state/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useCallback, useRef, useState } from 'react'; 3 | import { useRetimer } from '../use-retimer'; 4 | 5 | /** @see https://foxact.skk.moe/use-debounced-state */ 6 | export function useDebouncedState(defaultValue: T | (() => T), wait: number, leading = false) { 7 | const [value, setValue] = useState(defaultValue); 8 | const leadingRef = useRef(true); 9 | const retimer = useRetimer(); 10 | 11 | const debouncedSetValue = useCallback((newValue: T) => { 12 | if (leadingRef.current && leading) { 13 | setValue(newValue); 14 | } else { 15 | retimer(window.setTimeout(() => { 16 | leadingRef.current = true; 17 | setValue(newValue); 18 | }, wait)); 19 | } 20 | leadingRef.current = false; 21 | }, [leading, retimer, wait]); 22 | 23 | const forceSetValue = useCallback( 24 | (newValue: T) => { 25 | retimer(); 26 | setValue(newValue); 27 | }, 28 | [retimer] 29 | ); 30 | 31 | return [value, debouncedSetValue, forceSetValue] as const; 32 | } 33 | -------------------------------------------------------------------------------- /packages/docs/src/pages/typescript-happy-forward-ref.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: TypeScript Happy Forward Ref 3 | --- 4 | 5 | # TypeScript Happy Forward Ref 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Make React's `forwardRef` works with TypeScript in case: 12 | 13 | - You want to make a polymorphic component with forwarded ref 14 | - You want to pass generic type to `forwardRef` 15 | - You have encountered a strange typescript quirk when an interface function object can not be nested 16 | 17 | ## Usage 18 | 19 | Below is an example that uses `typescriptHappyForwardRef` to create a polymorphic button component with forwarded ref. 20 | 21 | ```tsx copy 22 | import { typescriptHappyForwardRef } from 'foxact/typescript-happy-forward-ref'; 23 | 24 | export type ButtonProps = React.ComponentPropsWithoutRef & { as?: T }; 25 | 26 | const Button = typescriptHappyForwardRef(({ as, ...rest }: ButtonProps, ref: React.ForwardedRef) => { 27 | const Comp = as || 'button'; 28 | 29 | return ; 30 | }); 31 | ``` 32 | -------------------------------------------------------------------------------- /packages/foxact/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sukka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/foxact/src/current-year/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { memo, useState } from 'react'; 4 | import { useIsomorphicLayoutEffect } from '../use-isomorphic-layout-effect'; 5 | import type { Foxact } from '../types'; 6 | 7 | interface CurrentYearProps extends Foxact.ComponentProps<'span'> { 8 | defaultYear?: number 9 | } 10 | 11 | /** @see https://foxact.skk.moe/current-year */ 12 | export const CurrentYear = memo(({ defaultYear, ...restProps }: Readonly) => { 13 | if (typeof window === 'undefined' && typeof defaultYear === 'undefined') { 14 | console.warn('[foxact/current-year] "defaultYear" is required during the server-side rendering.'); 15 | } 16 | 17 | const [year, setYear] = useState(defaultYear || new Date().getFullYear()); 18 | useIsomorphicLayoutEffect(() => { 19 | // This is only allowed because it won't trigger infinite re-render and double render is intentional 20 | 21 | setYear(new Date().getFullYear()); 22 | }, []); 23 | 24 | return {year}; 25 | }); 26 | 27 | if (process.env.NODE_ENV !== 'production') { 28 | CurrentYear.displayName = 'CurrentYear'; 29 | } 30 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-react-router-is-match.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useReactRouterIsMatch (React Router v6) 3 | --- 4 | 5 | # useReactRouterIsMatch (React Router v6) 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Programmatically returns whether the current location matches the given path. Useful when building a navigation menu, such as a breadcrumb or a set of tabs where you'd like to show which of them is currently selected. This is designed to be an alternative of React Router DOM v6's `` component's render prop approach. 12 | 13 | ## Usage 14 | 15 | ```tsx 16 | import { clsx } from 'clsx'; 17 | import * as styles from './navbar.module.css'; 18 | import { Link } from 'react-router-dom'; 19 | import { useReactRouterIsMatch } from 'foxact/use-react-router-is-match'; 20 | 21 | interface NavLinkProps { 22 | to: string 23 | } 24 | 25 | const NavLink = ({ to }: NavLinkProps) => { 26 | const isActive = useReactRouterIsMatch(to); 27 | 28 | return ( 29 | 30 | {isActive ? 'You are already here!' : 'Click Me!'} 31 | 32 | ) 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-isomorphic-layout-effect.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useIsomorphicLayoutEffect 3 | --- 4 | 5 | # useIsomorphicLayoutEffect 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | `useLayoutEffect` that does not show warning when server-side rendering. 12 | 13 | ## Usage 14 | 15 | ```diff 16 | - import { useLayoutEffect } from 'react'; 17 | + import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; 18 | ``` 19 | 20 | Note that [`eslint-plugin-react-hooks`](https://www.npmjs.com/package/eslint-plugin-react-hooks) requires extra configuration in order to check dependency array for third-party hooks: 21 | 22 | ```json filename=".eslintrc.json" copy 23 | { 24 | "rules": { 25 | "react-hooks/exhaustive-deps": [ 26 | "warn", 27 | { 28 | "additionalHooks": "useIsomorphicLayoutEffect" 29 | } 30 | ] 31 | } 32 | } 33 | ``` 34 | 35 | But if you do not want to configure it, `foxact/use-isomorphic-layout-effect` also provides another named export `useLayoutEffect` as an alias of `useIsomorphicLayoutEffect`: 36 | 37 | ```diff 38 | - import { useLayoutEffect } from 'react'; 39 | + import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; 40 | ``` 41 | -------------------------------------------------------------------------------- /packages/docs/src/pages/request-idle-callback.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Request Idle Callback 3 | --- 4 | 5 | # Request Idle Callback 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | A [ponyfill](https://github.com/sindresorhus/ponyfill) for `requestIdleCallback`, because [Safari (a.k.a Modern IE) refuses to support it](https://caniuse.com/?search=requestIdleCallback). 12 | 13 | From [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback): 14 | 15 | > The `window.requestIdleCallback()` method queues a function to be called during a browser's idle periods. This enables developers to perform background and low-priority work on the main event loop, without impacting latency-critical events such as animation and input response. 16 | 17 | ## Usage 18 | 19 | ```tsx copy 20 | import { useEffect } from 'react'; 21 | import { requestIdleCallback, cancelIdleCallback } from 'foxact/request-idle-callback'; 22 | 23 | const useIsIdle = () => { 24 | const [isIdle, setIsIdle] = useState(false); 25 | useEffect(() => { 26 | const idleHandle = requestIdleCallback(() => { 27 | setIsIdle(true); 28 | }); 29 | 30 | return () => { 31 | cancelIdleCallback(idleHandle); 32 | } 33 | }); 34 | 35 | return isIdle; 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /packages/docs/src/pages/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | --- 4 | 5 | import { Tabs, Tab } from 'nextra-theme-docs' 6 | 7 | # Getting Started 8 | 9 | ## Installation 10 | 11 | Inside your React project directory, run the following: 12 | 13 | 14 | 15 | ```bash 16 | npm i foxact 17 | ``` 18 | 19 | 20 | ```bash 21 | pnpm add foxact 22 | ``` 23 | 24 | 25 | ```bash 26 | yarn add foxact 27 | ``` 28 | 29 | 30 | 31 | ## Usage Example 32 | 33 | Simply importing the functions you need from `foxact`: 34 | 35 | ```tsx filename="src/context/sidebar-active-provider.tsx" copy 36 | import { createContextState } from 'foxact/context-state'; 37 | 38 | const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false); 39 | 40 | export { 41 | SidebarActiveProvider, 42 | useSidebarActive, 43 | useSetSidebarActive 44 | }; 45 | ``` 46 | 47 | ```tsx filename="src/App.tsx" copy 48 | import { SidebarActiveProvider } from './context/sidebar-active-provider'; 49 | 50 | export default function App() { 51 | return ( 52 | 53 | 54 | 55 | ); 56 | } 57 | ``` 58 | 59 | And here you go! 60 | -------------------------------------------------------------------------------- /packages/foxact/src/use-fast-click/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useMediaQuery } from '../use-media-query'; 3 | 4 | export type FastClickReturn = Pick, 'onMouseDown' | 'onClick'>; 5 | 6 | /** @see https://foxact.skk.moe/use-fast-click */ 7 | export function useFastClick(callback: React.MouseEventHandler): FastClickReturn { 8 | const handler: React.MouseEventHandler = useCallback((e) => { 9 | // onMouseDown triggers on any mouse click, only respond to "main" clicks 10 | if (e.type === 'mousedown' && e.button !== 0) { 11 | return; 12 | } 13 | 14 | if ( 15 | process.env.NODE_ENV === 'development' 16 | && !(e.currentTarget instanceof HTMLDivElement) 17 | && !(e.currentTarget instanceof HTMLButtonElement) 18 | ) { 19 | // eslint-disable-next-line no-console -- in dev only warning 20 | console.warn('[foxact/use-fast-click] You should only use "useFastClick" on
or 37 | ); 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /packages/foxact/src/create-fixed-array/index.ts: -------------------------------------------------------------------------------- 1 | const arrayMap = new Map(); 2 | 3 | function makeArray(length: number) { 4 | const arr = Array.from(new Array(length).keys()); 5 | if (process.env.NODE_ENV === 'development') { 6 | Object.freeze(arr); 7 | } 8 | 9 | return arr; 10 | } 11 | 12 | export function createFixedArrayWithoutGC(length: number): readonly number[] { 13 | if (arrayMap.has(length)) { 14 | return arrayMap.get(length)!; 15 | } 16 | const arr = makeArray(length); 17 | arrayMap.set(length, arr); 18 | return arr; 19 | } 20 | 21 | const arrayWeakRefMap = new Map>(); 22 | 23 | export function createFixedArrayWithGC(length: number): readonly number[] { 24 | let ref: WeakRef | undefined; 25 | let array: readonly number[] | undefined; 26 | if (arrayWeakRefMap.has(length)) { 27 | ref = arrayWeakRefMap.get(length)!; 28 | array = ref.deref(); 29 | } 30 | 31 | if (!array) { 32 | array = makeArray(length); 33 | 34 | // every time a new array is created, we create a new WeakRef and update map 35 | ref = new WeakRef(array); 36 | arrayWeakRefMap.set(length, ref); 37 | } 38 | 39 | return array; 40 | } 41 | 42 | export const createFixedArray = typeof WeakRef === 'function' ? createFixedArrayWithGC : createFixedArrayWithoutGC; 43 | -------------------------------------------------------------------------------- /packages/foxact/src/use-debounced-value/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useState, useRef } from 'react'; 3 | import { useEffect } from '../use-abortable-effect'; 4 | 5 | type NotFunction = T extends Function ? never : T; 6 | 7 | /** @see https://foxact.skk.moe/use-debounced-value */ 8 | export function useDebouncedValue(value: NotFunction, wait: number, leading = false) { 9 | if (typeof value === 'function') { 10 | throw new TypeError('useDebouncedValue does not support function as value'); 11 | } 12 | 13 | const [outputValue, setOutputValue] = useState(value); 14 | const leadingRef = useRef(true); 15 | 16 | useEffect(signal => { 17 | let timeout: number | null = null; 18 | 19 | if (!signal.aborted) { 20 | if (leading && leadingRef.current) { 21 | leadingRef.current = false; 22 | // This only happens when leading is enabled 23 | // This won't trigger infinitly re-render as long as value is stable 24 | 25 | setOutputValue(value); 26 | } else { 27 | timeout = window.setTimeout(() => { 28 | leadingRef.current = true; 29 | setOutputValue(value); 30 | }, wait); 31 | } 32 | } 33 | 34 | return () => { 35 | if (timeout) { 36 | window.clearTimeout(timeout); 37 | } 38 | }; 39 | }, [value, leading, wait]); 40 | 41 | return outputValue; 42 | } 43 | -------------------------------------------------------------------------------- /packages/docs/src/pages/invariant-nullthrow.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: invariant & nullthrow 3 | --- 4 | 5 | import ExportMetaInfo from '../components/export-meta-info'; 6 | 7 | # invariant 8 | 9 | 10 | 11 | ```ts 12 | import { invariant } from 'foxact/invariant'; 13 | 14 | invariant(true); 15 | // => undefined 16 | invariant(false); 17 | // => undefined 18 | invariant(0); 19 | // => undefined 20 | invariant(''); 21 | // => undefined 22 | invariant(null); 23 | // Error: Value is null or undefined 24 | invariant(undefined); 25 | // Error: Value is null or undefined 26 | invariant(); 27 | // Error: Value is null or undefined 28 | 29 | 30 | let value: string | null; 31 | invariant(value); 32 | console.log(value); 33 | // ^?: string 34 | ``` 35 | 36 | # nullthrow 37 | 38 | 39 | 40 | ```ts 41 | import { nullthrow } from 'foxact/nullthrow'; 42 | 43 | nullthrow(true); 44 | // => undefined 45 | nullthrow(false); 46 | // => undefined 47 | nullthrow(0); 48 | // => undefined 49 | nullthrow(''); 50 | // => undefined 51 | nullthrow(null); 52 | // Error: Value is null or undefined 53 | nullthrow(undefined); 54 | // Error: Value is null or undefined 55 | nullthrow(); 56 | // Error: Value is null or undefined 57 | 58 | let nullable: string | null; 59 | const value = nullthrow(nullable); 60 | console.log(value); 61 | // ^: string 62 | ``` 63 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-uncontrolled.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useUncontrolled" 3 | --- 4 | 5 | # useUncontrolled 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Track value from uncontrolled input (so React won't re-renders during user input). 12 | 13 | ## Usage 14 | 15 | ```tsx copy 16 | import { useCallback } from 'react'; 17 | import useSWR from 'swr'; 18 | import fetcher from '@/lib/fetcher'; 19 | import { useUncontrolled } from 'foxact/use-uncontrolled'; 20 | 21 | const Demo = () => { 22 | const [searchQuery, handleCommitSearchQuery, searchInputRef] = useUncontrolled( 23 | '', // initial value for searchQuery state 24 | (value: string) => value.trim() // optional, transform the input value before committing to searchQuery state 25 | ); 26 | 27 | const { data: searchResults } = useSWR('/api/search?query=' + searchQuery, fetcher); 28 | 29 | return ( 30 |
31 |
{ 32 | e.preventDefault(); 33 | handleCommitSearchQuery(); 34 | // handleCommitSearchQuery is memoized as usual. 35 | }, [handleCommitSearchQuery])}> 36 | 37 | 38 |
39 | 40 | {searchResults?.map(item => ( 41 |
{item.name}
42 | )))} 43 |
44 | ) 45 | } 46 | ``` 47 | 48 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-composition-input.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useCompositionInput 3 | --- 4 | 5 | # useCompositionInput 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Track value of **uncontrolled** ``, but optimized for CJK (Chinese, Japanese, Korean, or other non-latin languages) IME. `useCompositionInput` accepts only one parameter `onChange`, which will only be triggered when the composition is finished and the value is actually emitted. 12 | 13 | The `useCompositionInput` is **uncontrolled** because the ``'s `value` attribute needs to reflect the current composition state, while the "real value" should not include compositions. Also, if `useCompositionInput` is controlling ``, the re-render count will be a lot more than the user's actual keystroke count due to the very nature of the CJK IME, which can make your React app less responsive. 14 | 15 | ## Usage 16 | 17 | ```tsx copy 18 | import { useCompositionInput } from 'foxact/use-composition-input'; 19 | import { useCallback } from 'react'; 20 | 21 | const Example = () => { 22 | const inputProps = useCompositionInput(useCallback((value: string) => { 23 | // Do something with the value 24 | }, [])); 25 | 26 | return ( 27 | 32 | ); 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-singleton.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useSingleton 3 | --- 4 | 5 | # useSingleton 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | The React documentation's ["Avoiding recreating the ref contents" pattern](https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents) as a hook. 12 | 13 | **Before:** 14 | 15 | ```tsx 16 | function Video() { 17 | const playerRef = useRef(null); 18 | if (playerRef.current === null) { 19 | playerRef.current = new VideoPlayer(); 20 | } 21 | // ... 22 | ``` 23 | 24 | **After:** 25 | 26 | ```tsx 27 | function Video() { 28 | const playerRef = useSingleton(() => new VideoPlayer()); 29 | // ... 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```tsx copy 35 | import { useCallback, useEffect } from 'react'; 36 | import { useSingleton } from 'foxact/use-singleton'; 37 | 38 | interface VideoProps { 39 | videoSrc: string; 40 | } 41 | 42 | function Video({ videoSrc }: VideoProps) { 43 | const playerRef = useSingleton(() => new VideoPlayer()); 44 | 45 | // callback ref to attach the player to the video element 46 | const videoElementCallbackRef = useCallback((el: HTMLVideoElement | null) => { 47 | if (el) { 48 | playerRef.current.attach(el); 49 | playerRef.current.load(videoSrc); 50 | } else { 51 | playerRef.current.destroy(); 52 | } 53 | }, [videoSrc]); 54 | 55 | return
}> 89 | 90 | 91 | ); 92 | 93 | // The server-generated HTML will include "fallback", and the user will see the "fallback" first, then the actual value stored in the browser's localStorage. 94 | ``` 95 | -------------------------------------------------------------------------------- /packages/foxact/tools/postbuild.ts: -------------------------------------------------------------------------------- 1 | import fsp from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { getEntries } from './get-entries'; 4 | import gzipSize from 'gzip-size'; 5 | import { file as brotliSizeFile } from 'brotli-size'; 6 | 7 | import zlib from 'node:zlib'; 8 | 9 | import type { PackageJson } from '@package-json/types'; 10 | 11 | const rootDir = process.cwd(); 12 | const distDir = path.resolve(rootDir, 'dist'); 13 | 14 | interface GzipStats { 15 | total: { raw: number, gzip: number, br: number }, 16 | exports: Record 17 | } 18 | 19 | function copyAndCreateFiles() { 20 | return Promise.all([ 21 | fsp.copyFile( 22 | path.resolve(rootDir, 'LICENSE'), 23 | path.resolve(distDir, 'LICENSE') 24 | ), 25 | fsp.copyFile( 26 | path.resolve(rootDir, 'README.md'), 27 | path.resolve(distDir, 'README.md') 28 | ), 29 | fsp.writeFile(path.resolve(distDir, 'ts_version_4.8_and_above_is_required.d.ts'), '') 30 | ]); 31 | } 32 | 33 | async function createPackageJson(entries: Record) { 34 | const packageJsonCopy = JSON.parse( 35 | await fsp.readFile(path.resolve(rootDir, 'package.json'), 'utf-8') 36 | ) as PackageJson; 37 | 38 | delete packageJsonCopy.devDependencies; 39 | delete packageJsonCopy.private; 40 | delete packageJsonCopy.scripts; 41 | 42 | packageJsonCopy.typeVersions = { 43 | '>=4.8': { 44 | '*': ['*'] 45 | }, 46 | '*': { 47 | '*': ['ts_version_4.8_and_above_is_required.d.ts'] 48 | } 49 | }; 50 | 51 | packageJsonCopy.exports = { 52 | './package.json': './package.json', 53 | './sizes.json': './sizes.json' 54 | }; 55 | 56 | Object.keys(entries).forEach(entryName => { 57 | // This is an unnecessary check to make TypeScript happy 58 | // For some reason TypeScript ignores the assignment above 59 | packageJsonCopy.exports ??= {}; 60 | 61 | packageJsonCopy.exports[`./${entryName}`] = { 62 | types: `./${entryName}/index.d.ts`, 63 | import: { 64 | types: `./${entryName}/index.d.ts`, 65 | default: `./${entryName}/index.mjs` 66 | }, 67 | require: `./${entryName}/index.cjs`, 68 | default: `./${entryName}/index.cjs` 69 | }; 70 | }); 71 | 72 | await fsp.writeFile( 73 | path.resolve(distDir, 'package.json'), 74 | JSON.stringify(packageJsonCopy, null, 2) 75 | ); 76 | } 77 | 78 | async function createSizesJson(entries: Record) { 79 | const gzipSizeStat: GzipStats = { 80 | total: { raw: 0, gzip: 0, br: 0 }, 81 | exports: {} 82 | }; 83 | 84 | await Promise.all( 85 | Object.keys(entries) 86 | // .filter(([_entryName, filename]) => filename.endsWith('.mjs')) 87 | .map(async (entryName) => { 88 | const filePath = path.join(distDir, entryName, 'index.mjs'); 89 | 90 | const [fileSize, fileGzipSize, fileBrotliSize] = await Promise.all([ 91 | fsp.stat(filePath).then(stat => stat.size), 92 | // Cloudflare uses gzip level 8 and brotli level 4 as default 93 | // https://blog.cloudflare.com/this-is-brotli-from-origin/ 94 | gzipSize.file(filePath, { level: 8 }), 95 | brotliSizeFile(filePath, { 96 | [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, 97 | [zlib.constants.BROTLI_PARAM_QUALITY]: 4 98 | }) 99 | ]); 100 | 101 | gzipSizeStat.total.raw += fileSize; 102 | gzipSizeStat.total.gzip += fileGzipSize; 103 | 104 | gzipSizeStat.exports[entryName] = { 105 | raw: fileSize, 106 | gzip: fileGzipSize, 107 | br: fileBrotliSize 108 | }; 109 | }) 110 | ); 111 | 112 | await fsp.writeFile( 113 | path.resolve(distDir, 'sizes.json'), 114 | JSON.stringify(gzipSizeStat) 115 | ); 116 | } 117 | 118 | (async () => { 119 | const entriesPromise = getEntries(); 120 | 121 | await Promise.all([ 122 | copyAndCreateFiles(), 123 | createPackageJson(await entriesPromise), 124 | createSizesJson(await entriesPromise) 125 | ]); 126 | })(); 127 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-debounced-value.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useDebouncedValue 3 | --- 4 | 5 | # useDebouncedValue 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Debounces state changes of **controlled components**. This can be useful in case you want to perform a heavy operation based on react state, for example, send a search request. 12 | 13 | ## Differences with `useDeferredValue` 14 | 15 | [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue) is a new React built-in hook introduced in React 18. It is similar to `useDebouncedValue` but has a few differences: 16 | 17 | - `useDeferredValue` can suppress Suspense fallback from re-appearing when the value changes, which makes it useful in Suspense-based data fetching. 18 | - `useDeferredValue` is better suited to optimizing rendering because it is deeply integrated with React itself and adapts to the user’s device as it doesn’t require choosing any fixed delay. The deferred re-render would happen almost immediately and wouldn’t be noticeable if the user’s device is fast (e.g. powerful laptop), while the re-render could “lag behind” the user interaction proportionally to how slow the device is. 19 | - `useDeferredValue` are interruptible by default, e.g. if React is in the middle of re-rendering a large list, but the user makes another input, React will abandon the current re-render, handle the keystroke, and then start rendering in the background again. While `useDebouncedValue` is not interruptible and wouldn’t improve the "lagging" issue. 20 | - `useDeferredValue` **can not** reduce network requests. Though the re-render triggered by the `useDeferredValue` can be interrupted by user interaction, the returned JSX from the re-render can be discarded by React (due to being outdated during Concurrent Rendering), the re-render itself will always happen and the network request will always be fired. 21 | 22 | ## Differences with `useDebouncedState` 23 | 24 | [`useDebouncedState`](/use-debounced-state) is another hook provided by **foxact**. However, it is designed to work with un-controlled components: 25 | 26 | - `useDebouncedValue` is used for controlled components (e.g. both `value` and `onChange` prop). 27 | - `useDebouncedValue` can only be used if you have direct access to the original state value. 28 | 29 | ## Usage 30 | 31 | Below is an example of different use cases of `useDebouncedValue` and `useDeferredValue`, and how they can be used together: 32 | 33 | ```tsx copy 34 | import { useState, useDeferredValue } from 'react'; 35 | import useSWR from 'swr'; 36 | import { useDebouncedValue } from 'foxact/use-debounced-value'; 37 | 38 | const Example = () => { 39 | const [value, setValue] = useState(''); 40 | const debouncedValue = useDebouncedValue( 41 | value, 42 | // delay in ms 43 | 300, 44 | // optional, default to false. whether to immediately update the debounced value with the first call 45 | false 46 | ); 47 | 48 | // useDebouncedValue here is used to reduce network requests. 49 | const { data } = useSWR(debouncedValue ? `/api/search?q=${debouncedValue}` : null); 50 | 51 | // useDeferredValue here is used to opt-in the Concurrent Rendering, to improve the UX. 52 | // The value passed to useDeferredValue must be primitive values or memoized. Here we 53 | // are relying on useSWR to memoize the returned `data` (which will be an array). 54 | const deferredData = useDeferredValue(data); 55 | 56 | return ( 57 |
58 | {/** 59 | * The value we passed to can never be debounced nor deferred, so the UI 60 | * will always represent the user input immediately. 61 | */} 62 | setValue(e.target.value), [])} /> 63 | {/** 64 | * needs to be wrapped in React.memo, so that it will 65 | * only re-render when the `deferredData` changes, not when the `value` nor 66 | * `debouncedValue` changes. 67 | */} 68 | will be interruptible, as it will 70 | // be triggered by `useDeferredValue`. 71 | // So may be slow to render, the entire UI will always 72 | // remain responsive. 73 | list={deferredData} 74 | /> 75 |
76 | ); 77 | } 78 | ``` 79 | -------------------------------------------------------------------------------- /packages/docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from 'nextra-theme-docs'; 2 | import { useRouter } from 'next/router'; 3 | import { useMemo } from 'react'; 4 | 5 | import type { DocsThemeConfig } from 'nextra-theme-docs'; 6 | import { CurrentYear } from 'foxact/current-year'; 7 | 8 | import { withTrailingSlash } from 'ufo'; 9 | 10 | const config: DocsThemeConfig = { 11 | logo: ( 12 |
13 | 14 | foxact 15 |
16 | ), 17 | project: { 18 | link: 'https://github.com/sukkaw/foxact' 19 | }, 20 | i18n: [], 21 | docsRepositoryBase: 'https://github.com/SukkaW/foxact/tree/master/packages/docs/', 22 | gitTimestamp() { 23 | return null; 24 | }, 25 | head() { 26 | // Custom goes here 27 | // const config = useConfig(); 28 | // const { route } = useRouter(); 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }, 47 | useNextSeoProps() { 48 | const config = useConfig(); 49 | const title = config.frontMatter.title ? `${config.frontMatter.title} | foxact - Made by Sukka` : 'foxact - Made by Sukka'; 50 | const description = config.frontMatter.description ? config.frontMatter.description : 'React Hooks/Utils done right. For browser, SSR, and React Server Components. Made by Sukka (https://skk.moe)'; 51 | 52 | const { route } = useRouter(); 53 | const canonical = useMemo(() => new URL(withTrailingSlash(route), 'https://foxact.skk.moe').href, [route]); 54 | 55 | return { 56 | defaultTitle: 'foxact - Made by Sukka', 57 | title, 58 | description, 59 | canonical, 60 | openGraph: { 61 | url: canonical, 62 | title, 63 | siteName: 'foxact - React Hooks/Utils library made by Sukka', 64 | images: [ 65 | { 66 | url: 'https://img.skk.moe/gh/foxact-og.png', 67 | type: 'image/png', 68 | width: 1200, 69 | height: 675 70 | } 71 | ] 72 | }, 73 | twitter: { 74 | cardType: 'summary_large_image' 75 | } 76 | }; 77 | }, 78 | footer: { 79 | text() { 80 | return ( 81 | <> 82 | MIT License 83 | {' '}|{' '} 84 | Made with 85 | {' '} 86 | 87 | {' '} 88 | by 89 | {' '} 90 | {/* eslint-disable-next-line @eslint-react/dom/no-unsafe-target-blank -- my own website, safe */} 91 | Sukka 92 | {' '}|{' '} 93 | © 94 | {' '} 95 | 2023 96 | {' '} 97 | - 98 | {' '} 99 | 100 | 101 | ); 102 | } 103 | } 104 | }; 105 | 106 | export default config; 107 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-debounced-state.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useDebouncedState 3 | --- 4 | 5 | # useDebouncedState 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Debounces state changes of **un-controlled components**. This can be useful in case you want to perform a heavy operation based on react state, for example, send a search request. 12 | 13 | ## Differences with `useDeferredValue` 14 | 15 | [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue) is a new React built-in hook introduced in React 18. It has very different usages and goals than `useDebouncedState`: 16 | 17 | - `useDeferredValue` can only be used if you have direct access to the original state value, while `useDebouncedState` is designed to work when you don't have access to the original state value. 18 | - `useDeferredValue` can suppress Suspense fallback from re-appearing when the value changes, which makes it useful in Suspense-based data fetching. 19 | - `useDeferredValue` is better suited to optimizing rendering because it is deeply integrated with React itself and adapts to the user’s device as it doesn’t require choosing any fixed delay. The deferred re-render would happen almost immediately and wouldn’t be noticeable if the user’s device is fast (e.g. powerful laptop), while the re-render could “lag behind” the user interaction proportionally to how slow the device is. 20 | - `useDeferredValue` are interruptible by default, e.g. if React is in the middle of re-rendering a large list, but the user makes another input, React will abandon the current re-render, handle the keystroke, and then start rendering in the background again. While `useDebouncedValue` is not interruptible and wouldn’t improve the "lagging" issue. 21 | - `useDeferredValue` **can not** reduce network requests. Though the re-render triggered by the `useDeferredValue` can be interrupted by user interaction, the returned JSX from the re-render can be discarded by React (due to being outdated during Concurrent Rendering), the re-render itself will always happen and the network request will always be fired. 22 | 23 | ## Differences with `useDebouncedValue` 24 | 25 | [`useDebouncedValue`](/use-debounced-value) is another hook provided by **foxact**. However, it is designed to work with controlled components: 26 | 27 | - `useDebouncedState` is used for un-controlled components (The `defaultValue` prop and the `onChange` prop, no `value` prop) 28 | - With `useDebouncedState` you can not have direct access to the original state value. 29 | 30 | ## Usage 31 | 32 | Below is an example of different use cases of `useDebouncedState` and `useDeferredValue`, and how they can be used together: 33 | 34 | ```tsx copy 35 | import { useDeferredValue } from 'react'; 36 | import useSWR from 'swr'; 37 | import { useDebouncedState } from 'foxact/use-debounced-state'; 38 | 39 | const Example = () => { 40 | const [debouncedValue, debouncilySetState] = useDebouncedState( 41 | // The initial value or a pure function that will return the initial value, just like the useState 42 | initialValue, 43 | // delay in ms 44 | 300, 45 | // optional, default to false. whether to immediately update the debounced value with the first call 46 | false 47 | ); 48 | 49 | // The debounced value can reduce network requests. 50 | const { data } = useSWR(debouncedValue ? `/api/search?q=${debouncedValue}` : null); 51 | 52 | // useDeferredValue here is used to opt-in the Concurrent Rendering, to improve the UX. 53 | // The value passed to useDeferredValue must be primitive values or memoized. Here we 54 | // are relying on useSWR to memoize the returned `data` (which will be an array). 55 | const deferredData = useDeferredValue(data); 56 | 57 | return ( 58 |
59 | here is not controlled by React as we are not using the `value` prop. 61 | defaultValue={initialValue} 62 | // Although the `onChange` will be fired on every keystroke, the state update will be 63 | // debounced 64 | onChange={useCallback((e) => debouncilySetState(e.target.value), [debouncilySetState])} 65 | /> 66 | {/** 67 | * needs to be wrapped in React.memo, so that it will 68 | * only re-render when the `deferredData` changes, not when the `debouncedValue` changes. 69 | */} 70 | will be interruptible, as it will 72 | // be triggered by `useDeferredValue`. 73 | // So may be slow to render, the entire UI will always 74 | // remain responsive. 75 | list={deferredData} 76 | /> 77 |
78 | ); 79 | } 80 | ``` 81 | -------------------------------------------------------------------------------- /packages/docs/src/pages/use-react-router-enable-concurrent-navigation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: useReactRouterEnableConcurrentNavigation (React Router v6) 3 | --- 4 | 5 | # useReactRouterEnableConcurrentNavigation (React Router v6) 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Improve React Router v6's navigation experience with ``, `React.lazy()`, and `useReactRouterEnableConcurrentNavigation()` using the power of React Transition and React Concurrent Rendering. Addresses Dan Abramov's feature request [Remix issue #5763: Integrating Remix Router with React Transitions](https://github.com/remix-run/remix/issues/5763). 12 | 13 | ## Usage 14 | 15 | Below is an example of how to use ``, `React.lazy()`, and `useReactRouterEnableConcurrentNavigation()` to bring lazyload and smooth transition to your React Router v6 application: 16 | 17 | ```tsx filename="src/index.tsx" copy 18 | // The entry point of your app 19 | 20 | import { StrictMode } from 'react'; 21 | import { RouterProvider } from 'react-router-dom'; 22 | 23 | import { router } from './router'; 24 | 25 | function App() { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | const el = document.getElementById('app'); 34 | if (el) { 35 | createRoot(el).render(); 36 | } 37 | ``` 38 | 39 | ```tsx filename="src/router/index.tsx" copy 40 | // The router declaration 41 | 42 | import { lazy, memo } from 'react'; 43 | import { createBrowserRouter, useRouteError, isRouteErrorResponse } from 'react-router-dom'; 44 | 45 | import { RootLayout } from '@/layouts/root-layout'; 46 | import { DashboardLayout } from '@/layouts/sub-layout'; 47 | import { ProfileLayout } from '@/layouts/sub-layout'; 48 | 49 | const RouterErrorBoundary = memo(() => { 50 | const error = useRouteError(); 51 | if (isRouteErrorResponse(error)) { 52 | if (error.status === 404) { 53 | return ; 54 | } 55 | } 56 | return ; 57 | }); 58 | 59 | // Use `React.lazy()` for code splitting and lazy loading 60 | const Homepage = lazy(() => import(/* webpackPrefetch: true */ '@/pages/home')); 61 | const Dashboard = lazy(() => import(/* webpackPrefetch: true */ '@/pages/dashboard')); 62 | const Profile = lazy(() => import(/* webpackPrefetch: true */ '@/pages/profile')); 63 | 64 | export const router = createBrowserRouter([ 65 | { 66 | element: , 67 | errorElement: , 68 | children: [{ 69 | path: 'dashboard', 70 | element: , 71 | children: [ 72 | { 73 | index: true, 74 | element: 75 | }, 76 | // A common nested route pattern 77 | { 78 | path: 'profile', 79 | element: , 80 | children: [ 81 | { 82 | index: true, 83 | element: 84 | } 85 | ] 86 | } 87 | ] 88 | }] 89 | } 90 | ]); 91 | ``` 92 | 93 | In your root layout component (``), invoke `useReactRouterEnableConcurrentNavigation`: 94 | 95 | ```tsx filename="src/layouts/root-layout.tsx" copy 96 | import { Suspense } from 'react'; 97 | import { Outlet } from 'react-router-dom'; 98 | 99 | import { useReactRouterEnableConcurrentNavigation } from `foxact/use-react-router-enable-concurrent-navigation`; 100 | 101 | export const RootLayout = () => { 102 | useReactRouterEnableConcurrentNavigation(); 103 | 104 | return ( 105 | 106 | }> 107 | {/** 108 | * Wraps your with to for lazy loading 109 | * and concurrent rendering 110 | */} 111 | 112 | 113 | 114 | ) 115 | }; 116 | ``` 117 | 118 | If you prefer the Provider component approach, `foxact` also gets you covered: 119 | 120 | ```tsx filename="src/layouts/root-layout.tsx" copy 121 | import { Suspense } from 'react'; 122 | import { Outlet } from 'react-router-dom'; 123 | 124 | import { ReactRouterConcurrentNavigationProvider } from `foxact/use-react-router-enable-concurrent-navigation`; 125 | 126 | export const RootLayout = () => ( 127 | 128 | 129 | }> 130 | 131 | 132 | 133 | 134 | ); 135 | ``` 136 | 137 | Note that you should choose **one of** `useReactRouterEnableConcurrentNavigation` and `` in your app, **not both**. You only need to invoke it once in your app, and you should invoke it **under the ``** (to have access to `NavigationContext`). It is recommended to put it in your root layout component. 138 | 139 | Normally, if your app is built with React Router, ``, and `React.lazy()`, the Suspense fallback will always be shown during the navigation, resulting in a bad user experience. With `useReactRouterEnableConcurrentNavigation` or ``, Suspense fallback won't re-appear during the navigation, resulting in a smooth user experience. 140 | -------------------------------------------------------------------------------- /packages/docs/src/pages/context-state.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Context State 3 | --- 4 | 5 | # Context State 6 | 7 | import ExportMetaInfo from '../components/export-meta-info'; 8 | 9 | 10 | 11 | Store your shared state that lives only in React (without using any global state management library). Lift your state up and passing them deeply into your React app with [React Context](https://react.dev/learn/passing-data-deeply-with-context), without worrying about performance. 12 | 13 | ## Usage 14 | 15 | First, create a shared state provider along with getter and setter hooks with `createContextState`. It is recommended to place them in a separate file: 16 | 17 | ```tsx filename="src/context/sidebar-active.tsx" copy 18 | import { createContextState } from 'foxact/context-state'; 19 | // createContextState is also available from `foxact/create-context-state`: 20 | // import { createContextState } from 'foxact/create-context-state'; 21 | 22 | const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false); 23 | 24 | export { SidebarActiveProvider, useSidebarActive, useSetSidebarActive }; 25 | 26 | // You can also create your own hooks on top of the getter and setter hooks: 27 | export const useToggleSidebarActive = () => { 28 | const setSidebarActive = useSetSidebarActive(); 29 | // always use `useCallback` to memoize returned function, just like what `foxact` does: 30 | return useCallback(() => setSidebarActive(prevSidebarActive => !prevSidebarActive), [setSidebarActive]); 31 | // you can safely add it to the dependency array since `setSidebarActive` is also memoized 32 | }; 33 | ``` 34 | 35 | Then, wrap your app with the provider: 36 | 37 | ```tsx filename="src/layout/main-layout.tsx" copy 38 | import { memo } from 'react'; 39 | import { SidebarActiveProvider } from '../context/sidebar-active'; 40 | 41 | function MainLayout({ children }: React.PropsWithChildren) { 42 | return ( 43 | 44 |
45 | {children} 46 |
47 |
48 | ); 49 | } 50 | 51 | export default memo(MainLayout); 52 | ``` 53 | 54 | And now you can use the getter and setter hooks anywhere in your app: 55 | 56 | ```tsx filename="src/components/sidebar.tsx" copy 57 | import { memo } from 'react'; 58 | import { useSidebarActive, useSetSidebarActive } from '../context/sidebar-active'; 59 | 60 | function Sidebar() { 61 | const sidebarActive = useSidebarActive(); 62 | const setSidebarActive = useSetSidebarActive(); 63 | 64 | return ( 65 |
66 | 67 |
68 | ); 69 | } 70 | 71 | export default memo(Sidebar); 72 | ``` 73 | 74 | ```tsx filename="src/components/navbar.tsx" copy 75 | import { memo } from 'react'; 76 | import { useToggleSidebarActive } from '../context/sidebar-active'; 77 | 78 | function Navbar() { 79 | const toggleSidebarActive = useToggleSidebarActive(); 80 | 81 | return ( 82 |
83 | 84 |
85 | ); 86 | } 87 | 88 | export default memo(Navbar); 89 | ``` 90 | 91 | And when the sidebar active state is changed, only the component that uses `useSidebarActive()` hook will be re-rendered, in this case the only affected component is ``. 92 | 93 | ## Read context state conditionally 94 | 95 | Normally, you can not read context state conditionally per Rules of Hooks: 96 | 97 | > **Only Call Hooks at the Top Level**: Don’t call Hooks inside loops, conditions, or nested functions. 98 | 99 | However, if you do need to read the context state conditionally, you can use the new `React.use` introduced in React 18.3: 100 | 101 | ```tsx filename="src/context/sidebar-active.tsx" copy 102 | import { createContextState } from 'foxact/context-state'; 103 | // createContextState is also available from `foxact/create-context-state`: 104 | // import { createContextState } from 'foxact/create-context-state'; 105 | 106 | const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive, SidebarActiveContext] = createContextState(false); 107 | 108 | export { SidebarActiveProvider, useSidebarActive, useSetSidebarActive, SidebarActiveContext }; 109 | ``` 110 | 111 | ```tsx filename="src/components/sidebar.tsx" copy 112 | import { memo, use } from 'react'; 113 | import { SidebarActiveContext } from '../context/sidebar-active'; 114 | 115 | interface SidebarProps { 116 | loggedIn: boolean 117 | } 118 | 119 | function Sidebar({ loggedIn }: SidebarProps) { 120 | // This is only for the demonstration purpose to show `React.use` can be called conditionally 121 | if (!loggedIn) { 122 | return ( 123 |
124 |
Hello!
125 |
126 | ) 127 | } 128 | 129 | // Here we are using `use` to read the context state conditionally 130 | const sidebarActive = use(SidebarActiveContext); 131 | 132 | return ( 133 |
134 |
Welcome back, Sukka!
135 |
136 | ); 137 | } 138 | 139 | export default memo(Sidebar); 140 | ``` 141 | 142 | If you are not able to upgrade to React 18.3 or above, you can use the following risky approach by wielding the ancient dark magic of `React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED`: 143 | 144 | ```tsx filename="src/components/sidebar.tsx" copy 145 | import reactExports, { memo, use } from 'react'; 146 | import { SidebarActiveContext } from '../context/sidebar-active'; 147 | 148 | interface ReactInternal { 149 | __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: { 150 | ReactCurrentDispatcher: React.RefObject<{ readContext(context: React.Context): T }> 151 | } 152 | } 153 | 154 | interface SidebarProps { 155 | loggedIn: boolean 156 | } 157 | 158 | function Sidebar({ loggedIn }: SidebarProps) { 159 | if (!loggedIn) { 160 | return ( 161 |
162 |
Hello!
163 |
164 | ) 165 | } 166 | 167 | // I'm sure you want me to tell you how safe and stable it is, right? 168 | const sidebarActive = (reactExports as unknown as ReactInternal) 169 | .__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher 170 | ?.current 171 | ?.readContext(SidebarActiveContext)) 172 | 173 | return ( 174 |
175 |
Welcome back, Sukka!
176 |
177 | ); 178 | } 179 | 180 | export default memo(Sidebar); 181 | ``` 182 | -------------------------------------------------------------------------------- /packages/foxact/src/create-storage-hook/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import { useSyncExternalStore, useCallback, useMemo } from 'react'; 3 | import { noop } from '../noop'; 4 | import { useLayoutEffect } from '../use-isomorphic-layout-effect'; 5 | import { noSSRError } from '../no-ssr'; 6 | 7 | import { identity } from 'foxts/identity'; 8 | import { isFunction } from 'foxts/is-function'; 9 | 10 | export type StorageType = 'localStorage' | 'sessionStorage'; 11 | export type NotUndefined = T extends undefined ? never : T; 12 | 13 | export type StateHookTuple = readonly [T, React.Dispatch>]; 14 | 15 | // StorageEvent is deliberately not fired on the same document, we do not want to change that 16 | type CustomStorageEvent = CustomEvent; 17 | declare global { 18 | interface WindowEventMap { 19 | 'foxact-use-local-storage': CustomStorageEvent, 20 | 'foxact-use-session-storage': CustomStorageEvent 21 | } 22 | } 23 | 24 | export type Serializer = (value: T) => string; 25 | export type Deserializer = (value: string) => T; 26 | 27 | export interface UseStorageRawOption { 28 | raw: true 29 | } 30 | 31 | export interface UseStorageParserOption { 32 | raw?: false, 33 | serializer: Serializer, 34 | deserializer: Deserializer 35 | } 36 | 37 | function getServerSnapshotWithoutServerValue(): never { 38 | throw noSSRError('useLocalStorage cannot be used on the server without a serverValue'); 39 | } 40 | 41 | export function createStorage(type: StorageType) { 42 | const FOXACT_LOCAL_STORAGE_EVENT_KEY = type === 'localStorage' ? 'foxact-use-local-storage' : 'foxact-use-session-storage'; 43 | 44 | const foxactHookName = type === 'localStorage' ? 'foxact/use-local-storage' : 'foxact/use-session-storage'; 45 | 46 | const dispatchStorageEvent = typeof window === 'undefined' 47 | ? noop 48 | : (key: string) => { 49 | window.dispatchEvent(new CustomEvent(FOXACT_LOCAL_STORAGE_EVENT_KEY, { detail: key })); 50 | }; 51 | 52 | const setStorageItem = typeof window === 'undefined' 53 | ? noop 54 | : (key: string, value: string) => { 55 | try { 56 | window[type].setItem(key, value); 57 | } catch { 58 | console.warn(`[${foxactHookName}] Failed to set value to ${type}, it might be blocked`); 59 | } finally { 60 | dispatchStorageEvent(key); 61 | } 62 | }; 63 | 64 | const removeStorageItem = typeof window === 'undefined' 65 | ? noop 66 | : (key: string) => { 67 | try { 68 | window[type].removeItem(key); 69 | } catch { 70 | console.warn(`[${foxactHookName}] Failed to remove value from ${type}, it might be blocked`); 71 | } finally { 72 | dispatchStorageEvent(key); 73 | } 74 | }; 75 | 76 | const getStorageItem = (key: string) => { 77 | if (typeof window === 'undefined') { 78 | return null; 79 | } 80 | try { 81 | return window[type].getItem(key); 82 | } catch { 83 | console.warn(`[${foxactHookName}] Failed to get value from ${type}, it might be blocked`); 84 | return null; 85 | } 86 | }; 87 | 88 | const useSetStorage = (key: string, serializer: Serializer) => useCallback( 89 | (v: T | null) => { 90 | try { 91 | if (v === null) { 92 | removeStorageItem(key); 93 | } else { 94 | setStorageItem(key, serializer(v)); 95 | } 96 | } catch (e) { 97 | console.warn(e); 98 | } 99 | }, 100 | [key, serializer] 101 | ); 102 | 103 | // ssr compatible 104 | function useStorage( 105 | key: string, 106 | serverValue: NotUndefined, 107 | options?: UseStorageRawOption | UseStorageParserOption 108 | ): StateHookTuple; 109 | // client-render only 110 | function useStorage( 111 | key: string, 112 | serverValue?: undefined, 113 | options?: UseStorageRawOption | UseStorageParserOption 114 | ): StateHookTuple; 115 | function useStorage( 116 | key: string, 117 | serverValue?: NotUndefined, 118 | // eslint-disable-next-line sukka/unicorn/no-object-as-default-parameter -- two different shape of options 119 | options: UseStorageRawOption | UseStorageParserOption = { 120 | raw: false, 121 | serializer: JSON.stringify, 122 | deserializer: JSON.parse 123 | } 124 | ): StateHookTuple | StateHookTuple { 125 | const subscribeToSpecificKeyOfLocalStorage = useCallback((callback: () => void) => { 126 | if (typeof window === 'undefined') { 127 | return noop; 128 | } 129 | 130 | const handleStorageEvent = (e: StorageEvent) => { 131 | if ( 132 | (!('key' in e)) // Some browsers' strange quirk where StorageEvent is missing key 133 | || e.key === key 134 | ) { 135 | callback(); 136 | } 137 | }; 138 | const handleCustomStorageEvent = (e: CustomStorageEvent) => { 139 | if (e.detail === key) { 140 | callback(); 141 | } 142 | }; 143 | 144 | window.addEventListener('storage', handleStorageEvent); 145 | window.addEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent); 146 | return () => { 147 | window.removeEventListener('storage', handleStorageEvent); 148 | window.removeEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent); 149 | }; 150 | }, [key]); 151 | 152 | const serializer: Serializer = options.raw ? identity : options.serializer; 153 | const deserializer: Deserializer = options.raw ? identity : options.deserializer; 154 | 155 | const getClientSnapshot = () => getStorageItem(key); 156 | 157 | // If the serverValue is provided, we pass it to useSES' getServerSnapshot, which will be used during SSR 158 | // If the serverValue is not provided, we don't pass it to useSES, which will cause useSES to opt-in client-side rendering 159 | const getServerSnapshot = serverValue === undefined 160 | ? getServerSnapshotWithoutServerValue 161 | : () => serializer(serverValue); 162 | 163 | const store = useSyncExternalStore( 164 | subscribeToSpecificKeyOfLocalStorage, 165 | getClientSnapshot, 166 | getServerSnapshot 167 | ); 168 | 169 | const deserialized = useMemo(() => (store === null ? null : deserializer(store)), [store, deserializer]); 170 | 171 | const setState = useCallback>>( 172 | (v) => { 173 | try { 174 | const nextState = isFunction(v) 175 | ? v(deserialized ?? null) 176 | : v; 177 | 178 | if (nextState === null) { 179 | removeStorageItem(key); 180 | } else { 181 | setStorageItem(key, serializer(nextState)); 182 | } 183 | } catch (e) { 184 | console.warn(e); 185 | } 186 | }, 187 | [key, serializer, deserialized] 188 | ); 189 | 190 | useLayoutEffect(() => { 191 | if ( 192 | getStorageItem(key) === null 193 | && serverValue !== undefined 194 | ) { 195 | setStorageItem(key, serializer(serverValue)); 196 | } 197 | }, [deserializer, key, serializer, serverValue]); 198 | 199 | const finalValue: T | null = deserialized === null 200 | // storage doesn't have value 201 | ? (serverValue === undefined 202 | // no default value provided 203 | ? null 204 | : serverValue satisfies NotUndefined) 205 | // storage has value 206 | : deserialized satisfies T; 207 | 208 | return [finalValue, setState] as const; 209 | } 210 | 211 | return { 212 | useStorage, 213 | useSetStorage 214 | }; 215 | } 216 | -------------------------------------------------------------------------------- /packages/docs/src/pages/best-practice.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Best Practice 3 | --- 4 | 5 | # Best Practice 6 | 7 | ## Destructuring 8 | 9 | Some of the React Hooks in `foxact` will return **an object of memoized values**. You can use [destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) to extract the values you need, e.g: 10 | 11 | ```tsx copy 12 | import { useClipboard } from 'foxact/use-clipboard'; 13 | 14 | export default function App() { 15 | const { copied, copy } = useClipboard(); 16 | const handleCopyButtonClick = useCallback(() => copy('Hello World'), [copy]); 17 | 18 | return ( 19 |
20 | 21 |
22 | ); 23 | } 24 | ``` 25 | 26 | Here, the `copy` function returned by `useClipboard` is memoized, so it's safe to use it as a dependency in the `useCallback` hook. 27 | 28 | Other more re-usable utilities and hooks from `foxact` will return **an array of memoized values**, so you can give them a more descriptive name by using [array destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Array_destructuring), e.g: 29 | 30 | ```tsx filename="src/context/provider.tsx" copy 31 | import { createContextState } from 'foxact/context-state'; 32 | 33 | const [TokenProvider, useToken, useSetToken] = createContextState(null); 34 | const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false); 35 | 36 | const Provider = ({ children }: React.PropsWithChildren) => ( 37 | 38 | 39 | {children} 40 | 41 | 42 | ); 43 | 44 | export { 45 | Provider, 46 | useToken, useSetToken, 47 | useSidebarActive, useSetSidebarActive 48 | }; 49 | ``` 50 | 51 | ```tsx filename="src/App.tsx" copy 52 | import { useIntersection } from 'foxact/use-intersection'; 53 | 54 | const [setAvatarIntersection, isAvatarIntersected, resetAvatarIsIntersected] = useIntersection(); 55 | const [setThumbnailIntersection, isThumbnailIntersected, resetThumbnailIsIntersected] = useIntersection(); 56 | const setToken = useSetToken(); 57 | ``` 58 | 59 | And returned functions (like `setAvatarIntersection`, `resetAvatarIsIntersected`, `setThumbnailIntersection`, `resetThumbnailIsIntersected`, and `setToken`) are always memoized, so it's safe to add them to any dependencies array. 60 | 61 | ## Why / Why not 62 | 63 | ### Global state management library vs useContext + useState 64 | 65 | Global state management libraries (like [Redux](https://redux.js.org/) or [Zustand](https://github.com/pmndrs/zustand)) usually store the state outside of React, then use `useSyncExternalStore` to sync the state into the React. 66 | 67 | `useSyncExternalStore` is a new React built-in hook introduced in React 18. It is a trade-off between React Concurrent Rendering and consistency (without tearing). Currently (React 18.2), when React encounters `useSyncExternalStore` during the concurrent rendering, React will "de-optimize" and fall back to the synchronous rendering (slower and less responsive) to make sure there is no inconsistency (tearing). Only states stored inside of React (using `useState` and `useReducer`) are concurrent-rendering-friendly. 68 | 69 | Global state management libraries are indeed very powerful and great for React application that has complex logic and complicated data flow. But most of the time, a simple state shared across the application is good enough. 70 | 71 | ### Why not `useIsMounted` 72 | 73 | It is common to use `useIsMounted` to check if the component is mounted before calling `setState` during `useEffect`: 74 | 75 | ```tsx 76 | // ONLY FOR DEMONSTRATION, NEVER DO THIS IN REAL WORD REACT PROJECT 77 | const isMountedRef = useIsMountedRef(); 78 | 79 | useEffect(() => { 80 | someAsyncStuff().then(data => { 81 | if (isMountedRef.current) { 82 | // trying to avoid set state on unmounted component 83 | setData(data); 84 | // BUT NO, DO NOT TO THIS. THIS IS WRONG 85 | } 86 | }); 87 | }); 88 | ``` 89 | 90 | The real problem is that you are trying to avoid is not updating the state after the component has been unmounted, but **what you really should do** is to avoid updating the state after the current effect has been unsubscribed / cleanup-ed. 91 | 92 | Imagine this common race condition: 93 | 94 | ```tsx 95 | interface ExampleComponentProps { 96 | dataKey: 'data1' | 'data2' 97 | } 98 | 99 | const ExampleComponent = ({ dataKey }: ExampleComponentProps) => { 100 | const [data, setData] = useState(null); 101 | // ONLY FOR DEMONSTRATION, NEVER DO THIS IN REAL WORD REACT PROJECT 102 | const isMountedRef = useIsMountedRef(); 103 | 104 | useEffect(() => { 105 | someAsyncStuff(dataKey).then(data => { 106 | if (isMountedRef.current) { 107 | // trying to avoid set state on unmounted component 108 | setData(data); 109 | // BUT NO, DO NOT TO THIS. THIS IS WRONG 110 | } 111 | }); 112 | }, [dataKey]); 113 | 114 | return ( 115 |
{data}
116 | ); 117 | }; 118 | ``` 119 | 120 | ``` 121 | │ Request data 1 ────────────────────────────────────────► data1 response (setData(data1)) │ 122 | │ Request data 2 ────► data2 response (setData(data2)) │ 123 | ``` 124 | 125 | Here, although the request for `data1` happened before `data2`, the response for `data2` is received before `data1`. And `useIsMountedRef` doesn't help with that. 126 | 127 | To properly avoid `setData(data1)` from being called, the correct pattern is described below. 128 | 129 | ```tsx 130 | interface ExampleComponentProps { 131 | dataKey: 'data1' | 'data2' 132 | } 133 | 134 | const ExampleComponent = ({ dataKey }: ExampleComponentProps) => { 135 | const [data, setData] = useState(null); 136 | useEffect(() => { 137 | let isCancelled = false; 138 | 139 | someAsyncStuff().then(data => { 140 | if (!isCancelled) { 141 | setData(data); 142 | } 143 | }); 144 | 145 | return () => { 146 | isCancelled = true; 147 | }; 148 | }, [dataKey]); 149 | } 150 | ``` 151 | 152 | ``` 153 | │ Request data 1 ───────────────────────────────────────────────────────► data1 response │ 154 | | isCancelled: false | isCancelled: true | isCancelled: true, no setData(data1) 155 | │ Request data 2 ────► data2 response (setData(data2)) │ 156 | | isCancelled: false | isCancelled: false, setData(data2) 157 | ``` 158 | 159 | You can also use [`useAbortableEffect`](./use-abortable-effect) to achieve the same thing with less boilerplate code: 160 | 161 | ```tsx 162 | interface ExampleComponentProps { 163 | dataKey: 'data1' | 'data2' 164 | } 165 | 166 | const ExampleComponent = ({ dataKey }: ExampleComponentProps) => { 167 | const [data, setData] = useState(null); 168 | 169 | // Do notice that useAbortableEffect requires AbortController support 170 | useAbortableEffect((signal) => { 171 | someAsyncStuff().then(data => { 172 | if (signal.aborted) return 173 | setData(data); 174 | }); 175 | }, [dataKey]); 176 | } 177 | ``` 178 | 179 | ``` 180 | │ Request data 1 ───────────────────────────────────────────────────────► data1 response │ 181 | | aborted: false | aborted | aborted, no setData(data1) 182 | │ Request data 2 ────► data2 response (setData(data2)) │ 183 | | aborted: false | aborted: false, setData(data2) 184 | ``` 185 | -------------------------------------------------------------------------------- /packages/foxact/src/use-next-link/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | 3 | import type { LinkProps } from 'next/link'; 4 | import { useEffect, useMemo, useTransition, useCallback } from 'react'; 5 | import { useRouter } from 'next/navigation'; 6 | 7 | import { formatUrl } from 'next/dist/shared/lib/router/utils/format-url'; 8 | import { useIntersection } from '../use-intersection'; 9 | import type { UseIntersectionArgs } from '../use-intersection'; 10 | import { useComponentWillReceiveUpdate } from '../use-component-will-receive-update'; 11 | 12 | import type { 13 | PrefetchOptions as AppRouterPrefetchOptions 14 | } from 'next/dist/shared/lib/app-router-context.shared-runtime'; 15 | import type { PrefetchKind } from 'next/dist/client/components/router-reducer/router-reducer-types'; 16 | 17 | interface UrlObject { 18 | auth?: string | null | undefined, 19 | hash?: string | null | undefined, 20 | host?: string | null | undefined, 21 | hostname?: string | null | undefined, 22 | href?: string | null | undefined, 23 | pathname?: string | null | undefined, 24 | protocol?: string | null | undefined, 25 | search?: string | null | undefined, 26 | slashes?: boolean | null | undefined, 27 | port?: string | number | null | undefined, 28 | query?: any 29 | } 30 | 31 | export interface UseNextLinkOptions extends Omit { 38 | ref?: React.RefObject | React.RefCallback | null 39 | } 40 | 41 | export interface UseNextLinkReturnProps extends Partial { 42 | ref: React.RefCallback, 43 | onTouchStart: React.TouchEventHandler, 44 | onMouseEnter: React.MouseEventHandler, 45 | onClick: React.MouseEventHandler, 46 | href?: string 47 | } 48 | 49 | function isModifiedEvent(event: React.MouseEvent) { 50 | const eventTarget = event.currentTarget; 51 | const target = eventTarget.getAttribute('target'); 52 | return ( 53 | (target && target !== '_self') 54 | || eventTarget.download 55 | || event.metaKey 56 | || event.ctrlKey 57 | || event.shiftKey 58 | || event.altKey // triggers resource download 59 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-deprecated -- back compat 60 | || (event.nativeEvent?.which === 2) 61 | ); 62 | } 63 | 64 | // https://github.com/vercel/next.js/blob/39589ff35003ba73f92b7f7b349b3fdd3458819f/packages/next/src/client/components/router-reducer/router-reducer-types.ts#L148 65 | const PREFETCH_APPROUTER_AUTO = 'auto'; 66 | const PREFETCH_APPROUTER_FULL = 'full'; 67 | 68 | function prefetch(router: ReturnType, 69 | href: string, 70 | options: AppRouterPrefetchOptions) { 71 | if (typeof window === 'undefined') { 72 | return; 73 | } 74 | 75 | // Prefetch the RSC if asked (only in the client) 76 | // We need to handle a prefetch error here since we may be 77 | // loading with priority which can reject but we don't 78 | // want to force navigation since this is only a prefetch 79 | Promise.resolve(router.prefetch(href, options)).catch((err) => { 80 | if (process.env.NODE_ENV !== 'production') { 81 | // rethrow to show invalid URL errors 82 | throw err; 83 | } 84 | }); 85 | } 86 | 87 | const intersectionArgs: UseIntersectionArgs = { rootMargin: '200px' }; 88 | 89 | /** @see https://foxact.skk.moe/use-next-link */ 90 | function useNextLink(hrefProp: string | UrlObject, 91 | { 92 | prefetch: prefetchProp, 93 | ref, 94 | onClick, 95 | onMouseEnter, 96 | onTouchStart, 97 | scroll: routerScroll = true, 98 | replace = false, 99 | ...restProps // Record 100 | }: UseNextLinkOptions): [isPending: boolean, linkProps: UseNextLinkReturnProps] { 101 | /** 102 | * The possible states for prefetch are: 103 | * - null: this is the default "auto" mode, where we will prefetch partially if the link is in the viewport 104 | * - true: we will prefetch if the link is visible and prefetch the full page, not just partially 105 | * - false: we will not prefetch if in the viewport at all 106 | */ 107 | const appPrefetchKind = prefetchProp == null ? PREFETCH_APPROUTER_AUTO : PREFETCH_APPROUTER_FULL; 108 | const prefetchEnabled = prefetchProp !== false; 109 | 110 | const router = useRouter(); 111 | 112 | const [isPending, startTransition] = useTransition(); 113 | 114 | const [setIntersectionRef, isVisible, resetVisible] = useIntersection(intersectionArgs); 115 | 116 | const resolvedHref = useMemo(() => (typeof hrefProp === 'string' ? hrefProp : formatUrl(hrefProp)), [hrefProp]); 117 | useComponentWillReceiveUpdate(resetVisible, [resolvedHref]); 118 | 119 | // Prefetch the URL if we haven't already and it's visible. 120 | useEffect(() => { 121 | // in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page. 122 | if (process.env.NODE_ENV !== 'production') { 123 | return; 124 | } 125 | 126 | // If we don't need to prefetch the URL, don't do prefetch. 127 | if (!isVisible || !prefetchEnabled) { 128 | return; 129 | } 130 | 131 | // Prefetch the URL. 132 | prefetch( 133 | router, 134 | resolvedHref, 135 | { 136 | kind: appPrefetchKind as PrefetchKind 137 | } 138 | ); 139 | }, [appPrefetchKind, isVisible, prefetchEnabled, resolvedHref, router]); 140 | 141 | const childProps: UseNextLinkReturnProps = { 142 | ref: useCallback>((el: HTMLAnchorElement | null) => { 143 | // track the element visibility 144 | setIntersectionRef(el); 145 | 146 | if (typeof ref === 'function') { 147 | ref(el); 148 | } else if (ref && el) { 149 | // eslint-disable-next-line react-hooks/immutability -- this in inside a callback ref 150 | ref.current = el; 151 | } 152 | }, [ref, setIntersectionRef]), 153 | onClick: useCallback((e) => { 154 | if (typeof onClick === 'function') { 155 | onClick(e); 156 | } 157 | if (e.defaultPrevented) { 158 | return; 159 | } 160 | 161 | const { nodeName } = e.currentTarget; 162 | // anchors inside an svg have a lowercase nodeName 163 | if ( 164 | nodeName.toUpperCase() === 'A' 165 | && isModifiedEvent(e) 166 | ) { 167 | // app-router supports external urls out of the box 168 | // ignore click for browser’s default behavior 169 | return; 170 | } 171 | 172 | e.preventDefault(); 173 | 174 | startTransition(() => { 175 | router[replace ? 'replace' : 'push'](resolvedHref, { scroll: routerScroll }); 176 | }); 177 | }, [onClick, replace, resolvedHref, router, routerScroll]), 178 | onMouseEnter: useCallback((e) => { 179 | if (typeof onMouseEnter === 'function') { 180 | onMouseEnter(e); 181 | } 182 | // Always disable prefetching during the development 183 | if (process.env.NODE_ENV === 'development') { 184 | return; 185 | } 186 | if (!prefetchEnabled) { 187 | return; 188 | } 189 | 190 | // Prefetch the URL. 191 | prefetch( 192 | router, 193 | resolvedHref, 194 | { 195 | kind: appPrefetchKind as PrefetchKind 196 | } 197 | ); 198 | }, [appPrefetchKind, onMouseEnter, prefetchEnabled, resolvedHref, router]), 199 | onTouchStart: useCallback((e) => { 200 | if (typeof onTouchStart === 'function') { 201 | onTouchStart(e); 202 | } 203 | // Always disable prefetching during the development 204 | if (process.env.NODE_ENV === 'development') { 205 | return; 206 | } 207 | if (!prefetchEnabled) { 208 | return; 209 | } 210 | 211 | // Prefetch the URL. 212 | prefetch( 213 | router, 214 | resolvedHref, 215 | { 216 | kind: appPrefetchKind as PrefetchKind 217 | } 218 | ); 219 | }, [appPrefetchKind, onTouchStart, prefetchEnabled, resolvedHref, router]), 220 | ...restProps 221 | }; 222 | 223 | return [ 224 | isPending, 225 | childProps 226 | ] as const; 227 | } 228 | 229 | export const unstable_useNextLink = useNextLink; 230 | --------------------------------------------------------------------------------