├── .github └── workflows │ ├── nodejs.yml │ ├── pkg-pr.yml │ ├── release.yml │ └── size-limit.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .size-limit.ts ├── LICENSE ├── README.md ├── biome.json ├── commitlint.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── __tests__ │ ├── public │ │ └── test-script.js │ ├── useCookie.test.ts │ ├── useDebouncedCallback.test.tsx │ ├── useDebouncedValue.test.tsx │ ├── useElementSize.test.tsx │ ├── useMedia.test.tsx │ ├── usePrevious.test.tsx │ ├── useScript.test.tsx │ ├── useStorage.test.ts │ └── useWindowSize.test.tsx ├── helpers │ ├── cookies.ts │ ├── listeners.ts │ └── object-to-query.ts ├── hooks │ ├── useCookie.ts │ ├── useDebouncedCallback.ts │ ├── useDebouncedValue.ts │ ├── useElementSize.ts │ ├── useMedia.ts │ ├── usePrevious.ts │ ├── useScript.ts │ ├── useStorage.ts │ └── useWindowSize.ts └── index.ts ├── tools └── sync-exports.js ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - run: corepack enable 11 | - uses: actions/checkout@v4 12 | - name: Setup Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: "pnpm" 17 | - name: Install dependencies 18 | run: pnpm install 19 | - name: Lint 20 | run: pnpm biome ci . 21 | - name: Build 22 | run: pnpm build 23 | 24 | test_matrix: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | react: 30 | - 18 31 | - latest 32 | - rc 33 | steps: 34 | - run: corepack enable 35 | - uses: actions/checkout@v4 36 | - name: Setup Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 20 40 | cache: "pnpm" 41 | - name: Install dependencies 42 | run: pnpm install 43 | - name: Install legacy React types 44 | if: ${{ startsWith(matrix.react, '18') }} 45 | run: pnpm add -D @types/react@${{ matrix.react }} @types/react-dom@${{ matrix.react }} 46 | - name: Install ${{ matrix.react }} 47 | run: pnpm add -D react@${{ matrix.react }} react-dom@${{ matrix.react }} 48 | - name: Validate types 49 | run: pnpm tsc 50 | - name: Run tests 51 | run: | 52 | pnpm exec playwright install --with-deps 53 | pnpm test 54 | -------------------------------------------------------------------------------- /.github/workflows/pkg-pr.yml: -------------------------------------------------------------------------------- 1 | name: Publish Pull Requests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | pr-package: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - run: corepack enable 9 | - uses: actions/checkout@v4 10 | - name: Setup Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: 20 14 | cache: "pnpm" 15 | - name: Install dependencies 16 | run: pnpm install 17 | - name: Build 18 | run: pnpm build 19 | - name: Publish preview package 20 | run: pnpx pkg-pr-new publish --no-template 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - run: corepack enable 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: '0' # Avoid shallow clone 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | cache: 'pnpm' 24 | 25 | - run: npx changelogithub 26 | env: 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/size-limit.yml: -------------------------------------------------------------------------------- 1 | name: "size" 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | permissions: 7 | pull-requests: write 8 | jobs: 9 | size: 10 | runs-on: ubuntu-latest 11 | env: 12 | CI_JOB_NUMBER: 1 13 | steps: 14 | - run: corepack enable 15 | - uses: actions/checkout@v4 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: "pnpm" 21 | - uses: andresz1/size-limit-action@v1 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .cache 3 | node_modules 4 | reports 5 | example 6 | lib/ 7 | dist/ 8 | coverage/ 9 | npm-debug.log* 10 | .DS_store 11 | .eslintcache 12 | .stylelintcache 13 | .idea 14 | .tern 15 | .tmp 16 | *.log 17 | report.* 18 | 19 | __screenshots__ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.* 2 | !**/*.md -------------------------------------------------------------------------------- /.size-limit.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { globbySync } from "globby"; 3 | import type { SizeLimitConfig } from "size-limit"; 4 | 5 | const toCamelCase = (str: string) => 6 | str.replace(/-([a-z])/g, (_, m) => m.toUpperCase()); 7 | 8 | // Get all hooks from the `src/hooks` directory, and validate their size 9 | const limits = globbySync("dist/hooks/use*.js").map((file) => { 10 | const name = path.parse(file).name; 11 | 12 | return { 13 | name: name, 14 | path: file, 15 | import: `{ ${toCamelCase(name)} }`, 16 | limit: "1 KB", 17 | }; 18 | }) satisfies SizeLimitConfig; 19 | 20 | module.exports = limits; 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Charlie Tango 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Charlie Tango Hooks 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![License][license-src]][license-href] 5 | 6 | Collection of React Hooks used by [Charlie Tango](https://www.charlietango.dk/). 7 | 8 | - Written in TypeScript, with full types support. 9 | - Small and focused, each hook does one thing well. 10 | - No barrel file, only import the hooks you need. 11 | - Exported as ESM. 12 | - Optimized for modern React, uses newer APIs like `useSyncExternalStore`. 13 | - All hooks work in a server-side rendering environment. 14 | - All hooks are tested with [Vitest](https://vitest.dev/) in a real browser environment. 15 | 16 | ## Installation 17 | 18 | Install using npm: 19 | 20 | ```sh 21 | npm install @charlietango/hooks --save 22 | ``` 23 | 24 | ## The Hooks 25 | 26 | All the hooks are exported on their own, so we don't have a barrel file with all the hooks. 27 | This guarantees that you only import the hooks you need, and don't bloat your bundle with unused code. 28 | 29 | ### `useCookie` 30 | 31 | A hook to interact with the `document.cookie`. It works just like the `useState` hook, but it will persist the value in the cookie. 32 | The hook only sets and gets the `string` value - If you need to store an object, you need to serialize it yourself. 33 | 34 | ```ts 35 | import { useCookie } from "@charlietango/hooks/use-cookie"; 36 | 37 | const [value, setValue] = useCookie("mode"); 38 | ``` 39 | 40 | If the cookies is changed outside the `useCookie` hook, you can call the `revalidateCookies`, to get React to reevaluate the cookie values. 41 | 42 | ```ts 43 | import { revalidateCookies } from "@charlietango/hooks/use-cookie"; 44 | 45 | revalidateCookies(); 46 | ``` 47 | 48 | ### `useDebouncedValue` 49 | 50 | Debounce a value. The value will only be updated after the delay has passed without the value changing. 51 | 52 | ```ts 53 | import { useDebouncedValue } from "@charlietango/hooks/use-debounced-value"; 54 | 55 | const [debouncedValue, setDebouncedValue] = useDebouncedValue( 56 | initialValue, 57 | 500, 58 | ); 59 | 60 | setDebouncedValue("Hello"); 61 | setDebouncedValue("World"); 62 | console.log(debouncedValue); // Will log "Hello" until 500ms has passed 63 | ``` 64 | 65 | The `setDebouncedValue` also contains a few control methods, that can be useful: 66 | 67 | - `flush`: Call the callback immediately, and cancel debouncing. 68 | - `cancel`: Cancel debouncing, and the callback will never be called. 69 | - `isPending`: Check if the callback is waiting to be called. 70 | You can use them like this: 71 | 72 | ```tsx 73 | const [debouncedValue, setDebouncedValue] = useDebouncedValue( 74 | initialValue, 75 | 500, 76 | ); 77 | 78 | setDebouncedValue("Hello"); 79 | setDebouncedValue.isPending(); // true 80 | setDebouncedValue.flush(); // Logs "Hello" 81 | setDebouncedValue("world"); 82 | setDebouncedValue.cancel(); // Will never log "world" 83 | ``` 84 | 85 | ### `useDebouncedCallback` 86 | 87 | Debounce a callback function. The callback will only be called after the delay has passed without the function being called again. 88 | 89 | ```ts 90 | import { useDebouncedCallback } from "@charlietango/hooks/use-debounced-callback"; 91 | 92 | const debouncedCallback = useDebouncedCallback((value: string) => { 93 | console.log(value); 94 | }, 500); 95 | 96 | debouncedCallback("Hello"); 97 | debouncedCallback("World"); // Will only log "World" after 500ms 98 | ``` 99 | 100 | The `debouncedCallback` also contains a few control methods, that can be useful: 101 | 102 | - `flush`: Call the callback immediately, and cancel debouncing. 103 | - `cancel`: Cancel debouncing, and the callback will never be called. 104 | - `isPending`: Check if the callback is waiting to be called. 105 | 106 | You can use them like this: 107 | 108 | ```tsx 109 | const debouncedCallback = useDebouncedCallback((value: string) => { 110 | console.log(value); 111 | }, 500); 112 | 113 | debouncedCallback("Hello"); 114 | debouncedCallback.isPending(); // true 115 | debouncedCallback.flush(); // Logs "Hello" 116 | debouncedCallback("world"); 117 | debouncedCallback.cancel(); // Will never log "world" 118 | ``` 119 | 120 | ### `useElementSize` 121 | 122 | Monitor the size of an element, and return the size object. 123 | Uses the ResizeObserver API, so it will keep track of the size changes. 124 | 125 | ```ts 126 | import { useElementSize } from "@charlietango/hooks/use-element-size"; 127 | 128 | const { ref, size } = useElementSize(options); 129 | ``` 130 | 131 | ### `useMedia` 132 | 133 | Monitor a media query, and return a boolean indicating if the media query matches. Until the media query is matched, the hook will return `undefined`. 134 | 135 | ```ts 136 | import { useMedia } from "@charlietango/hooks/use-media"; 137 | 138 | const isDesktop = useMedia({ minWidth: 1024 }); 139 | const prefersReducedMotion = useMedia( 140 | "(prefers-reduced-motion: no-preference)", 141 | ); 142 | ``` 143 | 144 | ### `usePrevious` 145 | 146 | Keep track of the previous value of a variable. 147 | 148 | ```ts 149 | import { usePrevious } from "@charlietango/hooks/use-previous"; 150 | 151 | const prevValue = usePrevious(value); 152 | ``` 153 | 154 | ### `useScript` 155 | 156 | When loading external scripts, you might want to know when the script has loaded, and if there was an error. 157 | Because it's external, it won't be able to trigger a callback when it's done - Therefor you need to monitor the `