├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app ├── root.tsx ├── routes │ ├── _index.tsx │ ├── use-continuous-retry.tsx │ ├── use-debounce.tsx │ ├── use-fetch.tsx │ ├── use-intersection-observer.tsx │ ├── use-local-storage.tsx │ ├── use-media-query.tsx │ ├── use-network-state.tsx │ ├── use-orientation.tsx │ ├── use-preferred-language.tsx │ ├── use-previous.tsx │ ├── use-render-info.tsx │ ├── use-script.tsx │ ├── use-session-storage.tsx │ ├── use-visibility-change.tsx │ └── use-window-size.tsx └── tailwind.css ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | .vercel 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 50 React Hooks Explained 2 | 3 |
4 | 🍿 useDebounce 5 | 6 | --- 7 | 8 | This one is pretty straightforward. 9 | 10 | Every time value changes, we set a timeout to update the debounced value after the specified delay. 11 | 12 | However, if value keeps changing, we clear the timeout and set a new one. 13 | 14 | This means if you keep typing for a whole second without stopping, the debounced value will only be updated once at the end. 15 | 16 | ```tsx 17 | function useDebounce(value: string, delay: number) { 18 | // State to hold the debounced value 19 | const [debouncedValue, setDebouncedValue] = useState(value); 20 | 21 | useEffect(() => { 22 | // Handler to set debouncedValue to value after the specified delay 23 | const handler = setTimeout(() => { 24 | setDebouncedValue(value); 25 | }, delay); 26 | 27 | // Cleanup function to clear the timeout if the value or delay changes 28 | return () => { 29 | clearTimeout(handler); 30 | }; 31 | }, [value, delay]); 32 | 33 | return debouncedValue; 34 | } 35 | ``` 36 | 37 |
38 | 39 |
40 | 🍿 useLocalStorage 41 | 42 | --- 43 | 44 | Here we start off by getting the value from localStorage, if it exists. 45 | 46 | Using a function with the useState hook in React for the initial state is known as "lazy initialization." 47 | 48 | This method is handy when setting up the initial state takes a lot of work or relies on outside sources, like local storage. With this approach, React runs the function only once when the component first loads, enhancing performance by skipping extra work on future renders. 49 | 50 | When users set a new value, they may pass a function to the setValue function. This is a common pattern in React, where the new state depends on the previous state. 51 | 52 | Finally, we store the new value in localStorage. 53 | 54 | ```tsx 55 | function useLocalStorage( 56 | key: string, 57 | initialValue: InitialValue 58 | ) { 59 | const [storedValue, setStoredValue] = useState(() => { 60 | try { 61 | const item = window.localStorage.getItem(key); 62 | return item ? JSON.parse(item) : initialValue; 63 | } catch (error) { 64 | console.log(error); 65 | return initialValue; 66 | } 67 | }); 68 | 69 | const setValue = ( 70 | value: InitialValue | ((value: InitialValue) => InitialValue) 71 | ) => { 72 | try { 73 | const valueToStore = 74 | value instanceof Function ? value(storedValue) : value; 75 | setStoredValue(valueToStore); 76 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 77 | } catch (error) { 78 | console.log(error); 79 | } 80 | }; 81 | 82 | return [storedValue, setValue]; 83 | } 84 | ``` 85 | 86 |
87 | 88 |
89 | 🍿 useWindowSize 90 | 91 | --- 92 | 93 | The initial values of windowSize should be directly coming from `window` but because we're using SSR first framework, we need to set the initial values to `null` and update them on the first render. 94 | 95 | In an SPA application, this wouldn't be necessary. 96 | 97 | Whenever the window is resized, we update the windowSize state. 98 | 99 | Finally, we remove the event listener on cleanup. 100 | 101 | Reminder: Cleanup runs before the "new" effect, it runs with the old values of the effect. 102 | 103 | ```tsx 104 | function useWindowSize() { 105 | const [windowSize, setWindowSize] = useState<{ 106 | width: number | null; 107 | height: number | null; 108 | }>({ 109 | width: null, 110 | height: null, 111 | }); 112 | 113 | useEffect(() => { 114 | // Handler to call on window resize 115 | function handleResize() { 116 | // Set window width/height to state 117 | setWindowSize({ 118 | width: window.innerWidth, 119 | height: window.innerHeight, 120 | }); 121 | } 122 | 123 | window.addEventListener("resize", handleResize); 124 | 125 | // Call handler right away so state gets updated with initial window size 126 | // Needed because we're using SSR first framework 127 | handleResize(); 128 | 129 | // Remove event listener on cleanup 130 | return () => window.removeEventListener("resize", handleResize); 131 | }, []); 132 | 133 | return windowSize; 134 | } 135 | ``` 136 | 137 |
138 | 139 |
140 | 🍿 usePrevious 141 | 142 | --- 143 | 144 | # Description 145 | 146 | The trick with this hook is to use the `useRef` hook to store the previous value. 147 | 148 | The reason we use refs is because they don't cause a re-render when they change, unlike state. 149 | 150 | When we first call useRef, this happens before the component renders for the first time, so the ref's current value is `undefined`. 151 | 152 | Because useEffect runs after the component renders, the ref's current value will be the previous value. 153 | 154 | ```tsx 155 | function usePrevious(value: T) { 156 | const ref = useRef(); 157 | 158 | useEffect(() => { 159 | ref.current = value; 160 | }, [value]); 161 | 162 | return ref.current; 163 | } 164 | ``` 165 | 166 | # In depth explanation 167 | 168 | ## React's Update Cycle 169 | 170 | React's update cycle can be simplified into two main phases for our context: 171 | 172 | 1. **Rendering Phase:** React readies the UI based on the current state and props. This phase concludes with the virtual DOM being refreshed and arranged for applying to the actual DOM. Throughout this phase, your component function operates, executing any hooks invoked within it, such as `useState`, `useRef`, and the setup phase of `useEffect` (where you outline what the effect accomplishes, but it hasn't executed yet). 173 | 174 | 2. **Commit Phase:** React applies the changes from the virtual DOM to the actual DOM, making those changes visible to the user. This is when the UI is actually updated. 175 | 176 | ## Execution of `useEffect` 177 | 178 | `useEffect` is designed to run _after_ the commit phase. Its purpose is to execute side effects that should not be part of the rendering process, such as fetching data, setting up subscriptions, etc.. 179 | 180 | ## Why Changes in `useEffect` Don't Affect Current Cycle's DOM 181 | 182 | - **Timing:** Since `useEffect` runs after the commit phase, the DOM has already been updated with the information from the render phase by the time `useEffect` executes. React does not re-render or update the DOM again immediately after `useEffect` runs within the same cycle because React's rendering cycle has already completed. 183 | 184 | - **Intention:** This behavior is by design. React intentionally separates the effects from the rendering phase to ensure that the UI updates are efficient and predictable. If effects could modify the DOM immediately in the same cycle they run, it would lead to potential performance issues and bugs due to unexpected re-renders or state changes after the DOM has been updated. 185 | 186 | - **Ref and the DOM:** When you update `ref.current` in `useEffect`, you're modifying a value stored in memory that React uses for keeping references across renders. This update does not trigger a re-render by itself, and because `useEffect`'s changes are applied after the DOM has been updated, **there's no direct mechanism for those changes to modify the DOM until the next render cycle is triggered by state or prop changes.** 187 | 188 |
189 | 190 |
191 | 🍿 useIntersectionObserver 192 | 193 | --- 194 | 195 | `entry` gives us information about the target element's intersection with the root. 196 | 197 | The `isIntersecting` property tells us whether the element is visible in the viewport. 198 | 199 | As commented in the code, we copy `ref.current` to a variable to avoid a warning from React. 200 | 201 | **How it works in a nutshell:** In the useEffect, we create a new IntersectionObserver and observe the target element. We return a cleanup function that unobserves the target element. 202 | 203 | ```tsx 204 | function useIntersectionObserver(options: IntersectionObserverInit = {}) { 205 | const [entry, setEntry] = useState(null); 206 | const ref = useRef(null); 207 | 208 | useEffect(() => { 209 | const observer = new IntersectionObserver( 210 | ([entry]) => setEntry(entry), 211 | options 212 | ); 213 | 214 | // Copy ref.current to a variable 215 | // This is because ref.current may refer to a different element by the time the cleanup function runs 216 | // This was a warning by React 217 | // According to this Github issue: https://github.com/facebook/react/issues/15841 218 | // It's nothing to actually worry about 219 | const currentRef = ref.current; 220 | if (currentRef) observer.observe(currentRef); 221 | 222 | return () => { 223 | if (currentRef) observer.unobserve(currentRef); 224 | }; 225 | }, [options]); 226 | 227 | return [ref, entry] as const; 228 | } 229 | ``` 230 | 231 |
232 | 233 |
234 | 🍿 useNetworkState 235 | 236 | --- 237 | 238 | This hook is used to monitor the network state of the user. 239 | 240 | If you peek into the file `app/routes/use-network-state.tsx`, you'll see we had to author our own type for `navigator.connection` to avoid TypeScript errors. 241 | 242 | The main key here is to `navigator`, especially `navigator.connection`. 243 | 244 | Now, to be fair, this is an experimental API, as documented on MDN: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection. 245 | 246 | How it works in a nutshell: Similar to other hooks that use browser events, we set up event listeners for `online`, `offline`, and `change` events. 247 | 248 | `online` -> when browser goes online. 249 | `offline` -> when browser goes offline. 250 | `change` -> when the network state changes. 251 | 252 | ```tsx 253 | function useNetworkState() { 254 | const [networkState, setNetworkState] = useState({ 255 | online: false, 256 | }); 257 | 258 | useEffect(() => { 259 | const updateNetworkState = () => { 260 | setNetworkState({ 261 | online: navigator.onLine, 262 | downlink: navigator.connection?.downlink, 263 | downlinkMax: navigator.connection?.downlinkMax, 264 | effectiveType: navigator.connection?.effectiveType, 265 | rtt: navigator.connection?.rtt, 266 | saveData: navigator.connection?.saveData, 267 | type: navigator.connection?.type, 268 | }); 269 | }; 270 | 271 | // Call the function once to get the initial state 272 | updateNetworkState(); 273 | 274 | window.addEventListener("online", updateNetworkState); 275 | window.addEventListener("offline", updateNetworkState); 276 | navigator.connection?.addEventListener("change", updateNetworkState); 277 | 278 | return () => { 279 | window.removeEventListener("online", updateNetworkState); 280 | window.removeEventListener("offline", updateNetworkState); 281 | navigator.connection?.removeEventListener("change", updateNetworkState); 282 | }; 283 | }, []); 284 | 285 | return networkState; 286 | } 287 | ``` 288 | 289 |
290 | 291 |
292 | 🍿 useMediaQuery 293 | 294 | --- 295 | 296 | We set up a listener for the media query and update the matches state whenever the media query changes. 297 | 298 | The matches state is initially set to false, and it is set to true when the media query matches. 299 | 300 | We also return a cleanup function that removes the event listener when the component unmounts. 301 | 302 | This hook is useful for conditionally rendering content based on the state of a media query. 303 | 304 | For example, you can use it to show or hide certain elements based on the screen size. 305 | 306 | ```tsx 307 | function useMediaQuery(query: string) { 308 | const [matches, setMatches] = useState(false); 309 | 310 | useEffect(() => { 311 | const mediaQuery = window.matchMedia(query); 312 | setMatches(mediaQuery.matches); 313 | 314 | const listener = (event: MediaQueryListEvent) => { 315 | setMatches(event.matches); 316 | }; 317 | 318 | mediaQuery.addEventListener("change", listener); 319 | 320 | return () => { 321 | mediaQuery.removeEventListener("change", listener); 322 | }; 323 | }, [query]); 324 | 325 | return matches; 326 | } 327 | ``` 328 | 329 |
330 | 331 |
332 | 🍿 useOrientation 333 | 334 | --- 335 | 336 | This hook is used to monitor the orientation of the user's device. 337 | 338 | For example, you can use it to change the layout of your app based on the orientation of the device. 339 | 340 | Orientation means whether the device is in portrait or landscape mode, when e.g. holding your phone, you can hold it vertically or horizontally. 341 | 342 | We set up an event listener for the `orientationchange` event and update the orientation state whenever the orientation changes. 343 | 344 | ```tsx 345 | function useOrientation() { 346 | const [orientation, setOrientation] = useState( 347 | null 348 | ); 349 | 350 | useEffect(() => { 351 | const handleOrientationChange = () => { 352 | setOrientation(window.screen.orientation); 353 | }; 354 | 355 | // Set the initial orientation 356 | handleOrientationChange(); 357 | 358 | window.addEventListener("orientationchange", handleOrientationChange); 359 | 360 | return () => { 361 | window.removeEventListener("orientationchange", handleOrientationChange); 362 | }; 363 | }, []); 364 | 365 | return orientation; 366 | } 367 | ``` 368 | 369 |
370 | 371 |
372 | 🍿 useSessionStorage 373 | 374 | --- 375 | 376 | This hook is similar to the `useLocalStorage` hook, but it uses `sessionStorage` instead of `localStorage`. 377 | 378 | ```tsx 379 | function useSessionStorage( 380 | key: string, 381 | initialValue: InitialValue 382 | ) { 383 | const [value, setValue] = useState(() => { 384 | if (typeof window === "undefined") { 385 | return initialValue; 386 | } 387 | 388 | const storedValue = sessionStorage.getItem(key); 389 | return storedValue !== null ? JSON.parse(storedValue) : initialValue; 390 | }); 391 | 392 | // Set Inital Value 393 | useEffect(() => { 394 | setValue( 395 | JSON.parse(sessionStorage.getItem(key) || JSON.stringify(initialValue)) 396 | ); 397 | }, [initialValue, key]); 398 | 399 | useEffect(() => { 400 | sessionStorage.setItem(key, JSON.stringify(value)); 401 | }, [key, value]); 402 | 403 | return [value, setValue] as const; 404 | } 405 | ``` 406 | 407 |
408 | 409 |
410 | 🍿 usePreferredLanguage 411 | 412 | --- 413 | 414 | This hook is used to get the user's preferred language. 415 | 416 | It uses the `navigator.language` property to get the user's preferred language. 417 | 418 | Every time the user's preferred language changes, the `languagechange` event is fired, and we update the language state. 419 | 420 | ```tsx 421 | function usePreferredLanguage() { 422 | const [language, setLanguage] = useState(null); 423 | 424 | useEffect(() => { 425 | const handler = () => { 426 | setLanguage(navigator.language); 427 | }; 428 | 429 | // Set the initial language 430 | handler(); 431 | 432 | window.addEventListener("languagechange", handler); 433 | 434 | return () => { 435 | window.removeEventListener("languagechange", handler); 436 | }; 437 | }, []); 438 | 439 | return language; 440 | } 441 | ``` 442 | 443 |
444 | 445 |
446 | 🍿 useFetch 447 | 448 | --- 449 | 450 | This hook is used to fetch data from an API. 451 | 452 | It uses the `fetch` API to make a request to the specified URL. 453 | 454 | It's gonna fetch the data every time the URL changes. 455 | 456 | The `useEffect` hook is used to fetch the data when the URL changes. 457 | 458 | It returns an object with the data, loading state, and error. 459 | 460 | A common bad practice is to use boolean for the loading state, status is a better approach and more accurate 461 | 462 | ```tsx 463 | export function useFetch(url: string) { 464 | const [status, setStatus] = useState< 465 | "idle" | "loading" | "error" | "success" 466 | >("idle"); 467 | const [data, setData] = useState(null); 468 | const [error, setError] = useState(null); 469 | 470 | useEffect(() => { 471 | if (!url) return; 472 | setStatus("loading"); 473 | 474 | fetch(url) 475 | .then((res) => res.json()) 476 | .then((data) => { 477 | setData(data as Data); 478 | setStatus("success"); 479 | }) 480 | .catch((error) => { 481 | setError(error); 482 | setStatus("error"); 483 | }); 484 | }, [url]); 485 | 486 | return { error, isLoading: status === "loading", data }; 487 | } 488 | ``` 489 | 490 |
491 | 492 |
493 | 🍿 useContinuousRetry 494 | 495 | --- 496 | 497 | This hook is used to retry a function continuously until it returns true. 498 | 499 | The nice part here is that you can specify whatever you want to retry in the callback function. 500 | 501 | As commented why we need `useCallback`, it's because we want to retain the same reference across renders, unless its dependencies change. 502 | 503 | The callback function would change if e.g. the state inside the callback changes. 504 | 505 | Let's look at the route for example: 506 | 507 | ```tsx 508 | export default function UseContinuousRetryRoute() { 509 | const [count, setCount] = useState(0); 510 | const hasResolved = useContinuousRetry(() => count > 10, 1000, { 511 | maxRetries: 15, 512 | }); 513 | 514 | return ( 515 | // ... 516 | ); 517 | } 518 | ``` 519 | 520 | If `count` changes, the callback function would change, and `attemptRetry` would be re-created as a result. 521 | 522 | In the useEffect of the hook, we clean up the interval when the component unmounts. 523 | 524 | ```tsx 525 | interface UseContinuousRetryOptions { 526 | interval?: number; 527 | maxRetries?: number; 528 | } 529 | 530 | function useContinuousRetry( 531 | callback: () => boolean, 532 | interval: number = 100, 533 | options: UseContinuousRetryOptions = {} 534 | ) { 535 | const [hasResolved, setHasResolved] = useState(false); 536 | const [retryCount, setRetryCount] = useState(0); 537 | 538 | const maxRetries = options.maxRetries; 539 | 540 | // Using useCallback, the function retains the same reference across renders, 541 | // unless its dependencies change. This stability prevents unnecessary re-executions 542 | // of effects or callbacks that depend on this function, controlling the retry behavior as expected. 543 | // Without useCallback, the function would be re-created on every render, even if its dependencies haven't changed. 544 | const attemptRetry = useCallback(() => { 545 | if (callback()) { 546 | setHasResolved(true); 547 | return; 548 | } 549 | setRetryCount((count) => count + 1); 550 | }, [callback]); 551 | 552 | useEffect(() => { 553 | const hasRetryReachedLimit = 554 | maxRetries !== undefined && retryCount >= maxRetries; 555 | if (hasResolved || hasRetryReachedLimit) { 556 | return; 557 | } 558 | 559 | const id = setInterval(attemptRetry, interval); 560 | 561 | return () => clearInterval(id); 562 | }, [attemptRetry, hasResolved, interval, maxRetries, retryCount]); 563 | 564 | return hasResolved; 565 | } 566 | ``` 567 | 568 |
569 | 570 |
571 | 🍿 useVisibilityChange 572 | 573 | --- 574 | 575 | This hook is used to monitor the visibility state of the document. 576 | 577 | It's useful for when you want to pause or resume a video when the user switches tabs, for example. 578 | 579 | The `documentVisible` state is initially set to `null` and it is set to `true` when the document becomes visible. 580 | 581 | Because we're using an SSR first framework, we need to set the initial state in the `useEffect` hook. 582 | 583 | We also use `document.addEventListener` to listen for the `visibilitychange` event and update the `documentVisible` state accordingly. 584 | 585 | Finally, we use `document.removeEventListener` to remove the event listener when the component unmounts. 586 | 587 | ```tsx 588 | function useVisibilityChange() { 589 | const [documentVisible, setDocumentVisible] = useState(null); 590 | 591 | useEffect(() => { 592 | const handleVisibilityChange = () => { 593 | setDocumentVisible(document.visibilityState === "visible"); 594 | }; 595 | 596 | // Set the initial state 597 | handleVisibilityChange(); 598 | 599 | document.addEventListener("visibilitychange", handleVisibilityChange); 600 | 601 | return () => { 602 | document.removeEventListener("visibilitychange", handleVisibilityChange); 603 | }; 604 | }, []); 605 | 606 | return documentVisible; 607 | } 608 | ``` 609 | 610 |
611 | 612 |
613 | 🍿 useScript 614 | 615 | --- 616 | 617 | This hook is used to load an external script. 618 | 619 | It's useful for when you want to load a third-party script, like Google Analytics, for example. 620 | 621 | The `status` state is initially set to `loading` and it is set to `ready` when the script is loaded successfully, otherwise it is set to `error`. 622 | 623 | If script doesn't exist, we create a new script element and append it to the body. 624 | 625 | If it does exist, we set the status to `ready`. 626 | 627 | We also use `script.addEventListener` to listen for the `load` and `error` events and update the `status` accordingly. 628 | 629 | Finally, we use `script.removeEventListener` to remove the event listener when the component unmounts. 630 | 631 | ```tsx 632 | type ScriptStatus = "loading" | "ready" | "error"; 633 | 634 | function useScript( 635 | src: string, 636 | options?: { removeOnUnmount?: boolean } 637 | ): ScriptStatus { 638 | const [status, setStatus] = useState(src ? "loading" : "error"); 639 | 640 | const setReady = () => setStatus("ready"); 641 | const setError = () => setStatus("error"); 642 | 643 | useEffect(() => { 644 | let script: HTMLScriptElement | null = document.querySelector( 645 | `script[src="${src}"]` 646 | ); 647 | 648 | if (!script) { 649 | script = document.createElement("script"); 650 | script.src = src; 651 | script.async = true; 652 | document.body.appendChild(script); 653 | 654 | script.addEventListener("load", setReady); 655 | script.addEventListener("error", setError); 656 | } else { 657 | setStatus("ready"); 658 | } 659 | 660 | return () => { 661 | if (script) { 662 | script.removeEventListener("load", setReady); 663 | script.removeEventListener("error", setError); 664 | 665 | if (options?.removeOnUnmount) { 666 | script.remove(); 667 | } 668 | } 669 | }; 670 | }, [src, options?.removeOnUnmount]); 671 | 672 | return status; 673 | } 674 | ``` 675 | 676 |
677 | 678 |
679 | 🍿 useRenderInfo 680 | 681 | --- 682 | 683 | This hook is used to log information about the component's renders. 684 | 685 | During development, you may see 2 renders of a component and wonder why it's happening. It's because of React's StrictMode. 686 | 687 | This hook is useful when you want to log information about the component's renders e.g. when debugging performance issues. 688 | 689 | We use useRef to store the render information and log it to the console. Refs are perfect for this because they don't cause a re-render when they change, unlike state. And they persist across renders. 690 | 691 | The reason we don't need useEffect here is because we don't need to run the effect after the component renders, we just need to run it once when the component mounts, and as mentioned, to not lose the state, we use useRef. 692 | 693 | Another thing to not get confused: Because `info.timestamp` is updated after `info.sinceLast` is calculated, `info.sinceLast` will always be the time since the previous render, not the current one. That's why "now" is used when `info.timestamp` is null, which is the first render. 694 | 695 | ```tsx 696 | interface RenderInfo { 697 | readonly module: string; 698 | renders: number; 699 | timestamp: null | number; 700 | sinceLast: null | number | "[now]"; 701 | } 702 | 703 | const useRenderInfo = ( 704 | moduleName: string = "Unknown component", 705 | log: boolean = true 706 | ) => { 707 | const { current: info } = useRef({ 708 | module: moduleName, 709 | renders: 0, 710 | timestamp: null, 711 | sinceLast: null, 712 | }); 713 | 714 | const now = Date.now(); 715 | 716 | info.renders += 1; 717 | info.sinceLast = info.timestamp ? (now - info.timestamp) / 1000 : "[now]"; 718 | info.timestamp = now; 719 | 720 | if (log) { 721 | console.group(`${moduleName} info`); 722 | console.log( 723 | `Render no: ${info.renders}${ 724 | info.renders > 1 ? `, ${info.sinceLast}s since last render` : "" 725 | }` 726 | ); 727 | console.dir(info); 728 | console.groupEnd(); 729 | } 730 | 731 | return info; 732 | }; 733 | ``` 734 | 735 |
736 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { cssBundleHref } from "@remix-run/css-bundle"; 2 | import { 3 | Link, 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from "@remix-run/react"; 11 | import { Analytics } from "@vercel/analytics/react"; 12 | import type { LinksFunction } from "@vercel/remix"; 13 | 14 | import styles from "./tailwind.css"; 15 | 16 | export const links: LinksFunction = () => [ 17 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 18 | { rel: "stylesheet", href: styles }, 19 | ]; 20 | 21 | export default function App() { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@vercel/remix"; 2 | 3 | export const meta: MetaFunction = () => { 4 | return [ 5 | { title: "50 React hooks from scratch" }, 6 | { 7 | name: "description", 8 | content: "Building and documenting 50 react hooks from scratch", 9 | }, 10 | ]; 11 | }; 12 | 13 | const hooks = [ 14 | { 15 | name: "useDebounce", 16 | description: 17 | "Delay the execution of function or state update with useDebounce.", 18 | id: "use-debounce", 19 | }, 20 | { 21 | name: "useLocalStorage", 22 | description: 23 | "Persist state to local storage and keep it synchronized with useLocalStorage.", 24 | id: "use-local-storage", 25 | }, 26 | { 27 | name: "useWindowSize", 28 | description: 29 | "Track the dimensions of the browser window with useWindowSize.", 30 | id: "use-window-size", 31 | }, 32 | { 33 | name: "usePrevious", 34 | description: "Access the previous value of a state with usePrevious.", 35 | id: "use-previous", 36 | }, 37 | { 38 | name: "useIntersectionObserver", 39 | description: 40 | "Track the visibility of an element with useIntersectionObserver.", 41 | id: "use-intersection-observer", 42 | }, 43 | { 44 | name: "useNetworkState", 45 | description: 46 | "Monitor and adapt to network conditions seamlessly with useNetworkState.", 47 | id: "use-network-state", 48 | }, 49 | { 50 | name: "useMediaQuery", 51 | description: "Track the state of a media query with useMediaQuery.", 52 | id: "use-media-query", 53 | }, 54 | { 55 | name: "useOrientation", 56 | description: "Track the orientation of the device with useOrientation.", 57 | id: "use-orientation", 58 | }, 59 | { 60 | name: "useSessionStorage", 61 | description: "Persist state to session storage with useSessionStorage.", 62 | id: "use-session-storage", 63 | }, 64 | { 65 | name: "usePreferredLanguage", 66 | description: 67 | "Detect the user's preferred language with usePreferredLanguage.", 68 | id: "use-preferred-language", 69 | }, 70 | { 71 | name: "useFetch", 72 | description: "Fetch data with ease and flexibility with useFetch.", 73 | id: "use-fetch", 74 | }, 75 | { 76 | name: "useContinuousRetry", 77 | description: 78 | "Automates retries of a callback function until it succeeds with useContinuousRetry", 79 | id: "use-continuous-retry", 80 | }, 81 | { 82 | name: "useVisibilityChange", 83 | description: 84 | "Track the visibility state of the document with useVisibilityChange.", 85 | id: "use-visibility-change", 86 | }, 87 | { 88 | name: "useScript", 89 | description: "Load a script and track its state with useScript.", 90 | id: "use-script", 91 | }, 92 | { 93 | name: "useRenderInfo", 94 | description: "Access information about the render with useRenderInfo.", 95 | id: "use-render-info", 96 | }, 97 | ]; 98 | 99 | export default function Index() { 100 | return ( 101 |
102 |

Hooks

103 |

50 React Hooks built from scratch

104 | 113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /app/routes/use-continuous-retry.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | 3 | interface UseContinuousRetryOptions { 4 | interval?: number; 5 | maxRetries?: number; 6 | } 7 | 8 | function useContinuousRetry( 9 | callback: () => boolean, 10 | interval: number = 100, 11 | options: UseContinuousRetryOptions = {} 12 | ) { 13 | const [hasResolved, setHasResolved] = useState(false); 14 | const [retryCount, setRetryCount] = useState(0); 15 | 16 | const maxRetries = options.maxRetries; 17 | 18 | // Using useCallback, the function retains the same reference across renders, 19 | // unless its dependencies change. This stability prevents unnecessary re-executions 20 | // of effects or callbacks that depend on this function, controlling the retry behavior as expected. 21 | // Without useCallback, the function would be re-created on every render, even if its dependencies haven't changed. 22 | const attemptRetry = useCallback(() => { 23 | if (callback()) { 24 | setHasResolved(true); 25 | return; 26 | } 27 | setRetryCount((count) => count + 1); 28 | }, [callback]); 29 | 30 | useEffect(() => { 31 | const hasRetryReachedLimit = 32 | maxRetries !== undefined && retryCount >= maxRetries; 33 | if (hasResolved || hasRetryReachedLimit) { 34 | return; 35 | } 36 | 37 | const id = setInterval(attemptRetry, interval); 38 | 39 | return () => clearInterval(id); 40 | }, [attemptRetry, hasResolved, interval, maxRetries, retryCount]); 41 | 42 | return hasResolved; 43 | } 44 | 45 | export default function UseContinuousRetryRoute() { 46 | const [count, setCount] = useState(0); 47 | const hasResolved = useContinuousRetry(() => count > 10, 1000, { 48 | maxRetries: 5, 49 | }); 50 | 51 | return ( 52 |
53 |

useContinuousRetry

54 | 60 |
61 |
62 |           {JSON.stringify({ hasResolved, count }, null, 2)}
63 |         
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/routes/use-debounce.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent } from "react"; 2 | import { useEffect, useState } from "react"; 3 | 4 | function useDebounce(value: string, delay: number) { 5 | const [debouncedValue, setDebouncedValue] = useState(value); 6 | 7 | useEffect(() => { 8 | const handler = setTimeout(() => { 9 | setDebouncedValue(value); 10 | }, delay); 11 | 12 | return () => { 13 | clearTimeout(handler); 14 | }; 15 | }, [value, delay]); 16 | 17 | return debouncedValue; 18 | } 19 | 20 | export default function UseDebounceRoute() { 21 | const [searchTerm, setSearchTerm] = useState(""); 22 | 23 | const debouncedSearchTerm = useDebounce(searchTerm, 500); 24 | 25 | const handleSearch = (event: ChangeEvent) => { 26 | setSearchTerm(event.target.value); 27 | }; 28 | 29 | return ( 30 |
31 | 37 |

38 | Debounced Search Term: {debouncedSearchTerm} 39 |

40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/use-fetch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useFetch(url: string) { 4 | const [status, setStatus] = useState< 5 | "idle" | "loading" | "error" | "success" 6 | >("idle"); 7 | const [data, setData] = useState(null); 8 | const [error, setError] = useState(null); 9 | 10 | useEffect(() => { 11 | if (!url) return; 12 | setStatus("loading"); 13 | 14 | fetch(url) 15 | .then((res) => res.json()) 16 | .then((data) => { 17 | setData(data as Data); 18 | setStatus("success"); 19 | }) 20 | .catch((error) => { 21 | setError(error); 22 | setStatus("error"); 23 | }); 24 | }, [url]); 25 | 26 | return { error, isLoading: status === "loading", data }; 27 | } 28 | 29 | function Card({ 30 | loading, 31 | error, 32 | data, 33 | }: { 34 | loading: boolean; 35 | error: unknown; 36 | data: { 37 | name: string; 38 | sprites: { 39 | front_default: string; 40 | }; 41 | } | null; 42 | }) { 43 | if (loading) { 44 | return

Loading...

; 45 | } 46 | 47 | if (error instanceof Error) { 48 | return

Error: {error.message}

; 49 | } 50 | 51 | if (!data) { 52 | return null; 53 | } 54 | 55 | return ( 56 |
57 |

{data.name}

58 | {data.name} 63 |
64 | ); 65 | } 66 | 67 | export default function UseFetchRoute() { 68 | const [count, setCount] = useState(1); 69 | 70 | const { error, isLoading, data } = useFetch<{ 71 | name: string; 72 | sprites: { 73 | front_default: string; 74 | }; 75 | }>(`https://pokeapi.co/api/v2/pokemon/${count}`); 76 | 77 | return ( 78 |
79 |

useFetch

80 |
81 | 88 | 94 |
95 | 96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /app/routes/use-intersection-observer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | 3 | export default function UseIntersectionObserverRoute() { 4 | const [ref, entry] = useIntersectionObserver({ 5 | threshold: 0.9, 6 | }); 7 | 8 | return ( 9 |
10 |
11 | 12 |
16 | {entry?.isIntersecting 17 | ? "✅ Visible in viewport!" 18 | : "❌ Not visible yet."} 19 |
20 |
21 | ); 22 | } 23 | 24 | function useIntersectionObserver(options: IntersectionObserverInit = {}) { 25 | const [entry, setEntry] = useState(null); 26 | const ref = useRef(null); 27 | 28 | useEffect(() => { 29 | const observer = new IntersectionObserver( 30 | ([entry]) => setEntry(entry), 31 | options 32 | ); 33 | 34 | // Copy ref.current to a variable 35 | // This is because ref.current may refer to a different element by the time the cleanup function runs 36 | // This was a warning by React 37 | // According to this Github issue: https://github.com/facebook/react/issues/15841 38 | // It's nothing to actually worry about 39 | const currentRef = ref.current; 40 | if (currentRef) observer.observe(currentRef); 41 | 42 | return () => { 43 | if (currentRef) observer.unobserve(currentRef); 44 | }; 45 | }, [options]); 46 | 47 | return [ref, entry] as const; 48 | } 49 | -------------------------------------------------------------------------------- /app/routes/use-local-storage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ClientOnly } from "remix-utils/client-only"; 3 | 4 | function useLocalStorage( 5 | key: string, 6 | initialValue: InitialValue 7 | ) { 8 | const [storedValue, setStoredValue] = useState(() => { 9 | try { 10 | const item = window.localStorage.getItem(key); 11 | return item ? JSON.parse(item) : initialValue; 12 | } catch (error) { 13 | return initialValue; 14 | } 15 | }); 16 | 17 | const setValue = ( 18 | value: InitialValue | ((value: InitialValue) => InitialValue) 19 | ) => { 20 | try { 21 | const valueToStore = 22 | value instanceof Function ? value(storedValue) : value; 23 | setStoredValue(valueToStore); 24 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 25 | } catch (error) { 26 | console.log(error); 27 | } 28 | }; 29 | 30 | return [storedValue, setValue]; 31 | } 32 | 33 | export default function LocalStorageRoute() { 34 | const [name, setName] = useLocalStorage("name", "John Doe"); 35 | 36 | return ( 37 | 38 | {() => ( 39 |
40 |

useLocalStorage Hook Example

41 | setName(e.target.value)} 45 | /> 46 |

Your name is stored in local storage: {name}

47 |
48 | )} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/routes/use-media-query.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function useMediaQuery(query: string) { 4 | const [matches, setMatches] = useState(false); 5 | 6 | useEffect(() => { 7 | const mediaQuery = window.matchMedia(query); 8 | setMatches(mediaQuery.matches); 9 | 10 | const listener = (event: MediaQueryListEvent) => { 11 | setMatches(event.matches); 12 | }; 13 | 14 | mediaQuery.addEventListener("change", listener); 15 | 16 | return () => { 17 | mediaQuery.removeEventListener("change", listener); 18 | }; 19 | }, [query]); 20 | 21 | return matches; 22 | } 23 | 24 | export default function UseMediaQueryRoute() { 25 | const isSmallScreen = useMediaQuery("(max-width: 640px)"); 26 | 27 | return ( 28 |
29 |

30 | The current media query is{" "} 31 | "(max-width: 640px)" and it is{" "} 32 | {isSmallScreen ? "matched" : "not matched"}. 33 |

34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/routes/use-network-state.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | declare global { 4 | interface Navigator { 5 | connection?: { 6 | downlink: number; 7 | downlinkMax: number; 8 | effectiveType: string; 9 | rtt: number; 10 | saveData: boolean; 11 | type: string; 12 | addEventListener( 13 | type: "change", 14 | listener: (this: this, ev: Event) => any, 15 | options?: boolean | AddEventListenerOptions 16 | ): void; 17 | removeEventListener( 18 | type: "change", 19 | listener: (this: this, ev: Event) => any, 20 | options?: boolean | EventListenerOptions 21 | ): void; 22 | }; 23 | } 24 | } 25 | 26 | type NetworkState = { 27 | online: boolean; 28 | downlink?: number; 29 | downlinkMax?: number; 30 | effectiveType?: string; 31 | rtt?: number; 32 | saveData?: boolean; 33 | type?: string; 34 | }; 35 | 36 | function useNetworkState() { 37 | const [networkState, setNetworkState] = useState({ 38 | online: false, 39 | }); 40 | 41 | useEffect(() => { 42 | const updateNetworkState = () => { 43 | setNetworkState({ 44 | online: navigator.onLine, 45 | downlink: navigator.connection?.downlink, 46 | downlinkMax: navigator.connection?.downlinkMax, 47 | effectiveType: navigator.connection?.effectiveType, 48 | rtt: navigator.connection?.rtt, 49 | saveData: navigator.connection?.saveData, 50 | type: navigator.connection?.type, 51 | }); 52 | }; 53 | 54 | // Call the function once to get the initial state 55 | updateNetworkState(); 56 | 57 | window.addEventListener("online", updateNetworkState); 58 | window.addEventListener("offline", updateNetworkState); 59 | navigator.connection?.addEventListener("change", updateNetworkState); 60 | 61 | return () => { 62 | window.removeEventListener("online", updateNetworkState); 63 | window.removeEventListener("offline", updateNetworkState); 64 | navigator.connection?.removeEventListener("change", updateNetworkState); 65 | }; 66 | }, []); 67 | 68 | return networkState; 69 | } 70 | 71 | export default function UseNetworkStateRoute() { 72 | const network = useNetworkState(); 73 | 74 | return ( 75 |
76 |

Network State

77 |
78 | 79 | 80 | {Object.keys(network).map((key) => { 81 | return ( 82 | 83 | 84 | 87 | 88 | ); 89 | })} 90 | 91 |
{key}{`${ 85 | network[key as keyof typeof network] 86 | }`}
92 |
93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /app/routes/use-orientation.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function useOrientation() { 4 | const [orientation, setOrientation] = useState( 5 | null 6 | ); 7 | 8 | useEffect(() => { 9 | const handleOrientationChange = () => { 10 | setOrientation(window.screen.orientation); 11 | }; 12 | 13 | // Set the initial orientation 14 | handleOrientationChange(); 15 | 16 | window.addEventListener("orientationchange", handleOrientationChange); 17 | 18 | return () => { 19 | window.removeEventListener("orientationchange", handleOrientationChange); 20 | }; 21 | }, []); 22 | 23 | return orientation; 24 | } 25 | 26 | export default function UseOrientationRoute() { 27 | const orientation = useOrientation(); 28 | 29 | return ( 30 |
31 |

32 | The current orientation is{" "} 33 | {orientation?.type} and the angle is{" "} 34 | {orientation?.angle}. 35 |

36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/routes/use-preferred-language.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { ClientOnly } from "remix-utils/client-only"; 3 | 4 | function usePreferredLanguage() { 5 | const [language, setLanguage] = useState(null); 6 | 7 | useEffect(() => { 8 | const handler = () => { 9 | setLanguage(navigator.language); 10 | }; 11 | 12 | // Set the initial language 13 | handler(); 14 | 15 | window.addEventListener("languagechange", handler); 16 | 17 | return () => { 18 | window.removeEventListener("languagechange", handler); 19 | }; 20 | }, []); 21 | 22 | return language; 23 | } 24 | 25 | export default function UsePreferredLanguage() { 26 | const language = usePreferredLanguage(); 27 | 28 | return ( 29 | 30 | {() => ( 31 |
32 |

usePreferredLanguage

33 |

Change language here - chrome://settings/languages

34 |

35 | The correct date format for
{language}
is{" "} 36 | {language && ( 37 | 38 | )} 39 |

40 |
41 | )} 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/routes/use-previous.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | 3 | function usePrevious(value: T): T | undefined { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }, [value]); 9 | 10 | // Returns the previous value (happens before update in useEffect above) 11 | return ref.current; 12 | } 13 | 14 | export default function UsePreviousRoute() { 15 | const [count, setCount] = useState(0); 16 | const previousCount = usePrevious(count); 17 | 18 | return ( 19 |
20 |

usePrevious Example

21 |
22 | 28 |
29 |

Current Count: {count}

30 |

Previous Count: {previousCount}

31 |
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/routes/use-render-info.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | 3 | interface RenderInfo { 4 | readonly module: string; 5 | renders: number; 6 | timestamp: null | number; 7 | sinceLast: null | number | "[now]"; 8 | } 9 | 10 | const useRenderInfo = ( 11 | moduleName: string = "Unknown component", 12 | log: boolean = true 13 | ) => { 14 | const { current: info } = useRef({ 15 | module: moduleName, 16 | renders: 0, 17 | timestamp: null, 18 | sinceLast: null, 19 | }); 20 | 21 | const now = Date.now(); 22 | 23 | info.renders += 1; 24 | info.sinceLast = info.timestamp ? (now - info.timestamp) / 1000 : "[now]"; 25 | info.timestamp = now; 26 | 27 | if (log) { 28 | console.group(`${moduleName} info`); 29 | console.log( 30 | `Render no: ${info.renders}${ 31 | info.renders > 1 ? `, ${info.sinceLast}s since last render` : "" 32 | }` 33 | ); 34 | console.dir(info); 35 | console.groupEnd(); 36 | } 37 | 38 | return info; 39 | }; 40 | 41 | export default function UseRenderInfoRoute() { 42 | const [count, setCount] = useState(0); 43 | const renderInfo = useRenderInfo("UseRenderInfoRoute"); 44 | 45 | return ( 46 |
47 |

useRenderInfo

48 | 54 |
55 |

Module: {renderInfo.module}

56 |

Renders: {renderInfo.renders}

57 |

Timestamp: {renderInfo.timestamp}

58 |

Since Last Render: {renderInfo.sinceLast}s

59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /app/routes/use-script.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | type ScriptStatus = "loading" | "ready" | "error"; 4 | 5 | function useScript( 6 | src: string, 7 | options?: { removeOnUnmount?: boolean } 8 | ): ScriptStatus { 9 | const [status, setStatus] = useState(src ? "loading" : "error"); 10 | 11 | const setReady = () => setStatus("ready"); 12 | const setError = () => setStatus("error"); 13 | 14 | useEffect(() => { 15 | let script: HTMLScriptElement | null = document.querySelector( 16 | `script[src="${src}"]` 17 | ); 18 | 19 | if (!script) { 20 | script = document.createElement("script"); 21 | script.src = src; 22 | script.async = true; 23 | document.body.appendChild(script); 24 | 25 | script.addEventListener("load", setReady); 26 | script.addEventListener("error", setError); 27 | } else { 28 | setStatus("ready"); 29 | } 30 | 31 | return () => { 32 | if (script) { 33 | script.removeEventListener("load", setReady); 34 | script.removeEventListener("error", setError); 35 | 36 | if (options?.removeOnUnmount) { 37 | script.remove(); 38 | } 39 | } 40 | }; 41 | }, [src, options?.removeOnUnmount]); 42 | 43 | return status; 44 | } 45 | 46 | export default function UseScriptRoute() { 47 | const status = useScript( 48 | "https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.js" 49 | ); 50 | 51 | return ( 52 |
53 |

useScript Hook Example

54 |

55 | Script load status:{" "} 56 | 61 | {status} 62 | 63 |

64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /app/routes/use-session-storage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { ClientOnly } from "remix-utils/client-only"; 3 | 4 | function useSessionStorage( 5 | key: string, 6 | initialValue: InitialValue 7 | ) { 8 | const [value, setValue] = useState(() => { 9 | if (typeof window === "undefined") { 10 | return initialValue; 11 | } 12 | 13 | const storedValue = sessionStorage.getItem(key); 14 | return storedValue !== null ? JSON.parse(storedValue) : initialValue; 15 | }); 16 | 17 | useEffect(() => { 18 | setValue( 19 | JSON.parse(sessionStorage.getItem(key) || JSON.stringify(initialValue)) 20 | ); 21 | }, [initialValue, key]); 22 | 23 | useEffect(() => { 24 | sessionStorage.setItem(key, JSON.stringify(value)); 25 | }, [key, value]); 26 | 27 | return [value, setValue] as const; 28 | } 29 | 30 | export default function UseSessionStorageRoute() { 31 | const [value, setValue] = useSessionStorage("count", 0); 32 | 33 | return ( 34 | 35 | {() => ( 36 |
37 |

useSessionStorage

38 |

Count: {value}

39 | 42 | 43 |
44 | )} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/routes/use-visibility-change.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | function useVisibilityChange() { 4 | const [documentVisible, setDocumentVisible] = useState(null); 5 | 6 | useEffect(() => { 7 | const handleVisibilityChange = () => { 8 | setDocumentVisible(document.visibilityState === "visible"); 9 | }; 10 | 11 | // Set the initial state 12 | handleVisibilityChange(); 13 | 14 | document.addEventListener("visibilitychange", handleVisibilityChange); 15 | 16 | return () => { 17 | document.removeEventListener("visibilitychange", handleVisibilityChange); 18 | }; 19 | }, []); 20 | 21 | return documentVisible; 22 | } 23 | 24 | export default function UseVisibilityChangeRoute() { 25 | const documentVisible = useVisibilityChange(); 26 | const [tabAwayCount, setTabAwayCount] = useState(0); 27 | 28 | useEffect(() => { 29 | if (!documentVisible) { 30 | setTabAwayCount((c) => c + 1); 31 | } 32 | }, [documentVisible]); 33 | 34 | return ( 35 |
36 |

useVisibilityChange

37 |
Tab Away Count: {tabAwayCount}
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/routes/use-window-size.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | function useWindowSize() { 4 | const [windowSize, setWindowSize] = useState<{ 5 | width: number | null; 6 | height: number | null; 7 | }>({ 8 | width: null, 9 | height: null, 10 | }); 11 | 12 | useEffect(() => { 13 | // Handler to call on window resize 14 | function handleResize() { 15 | // Set window width/height to state 16 | setWindowSize({ 17 | width: window.innerWidth, 18 | height: window.innerHeight, 19 | }); 20 | } 21 | 22 | window.addEventListener("resize", handleResize); 23 | 24 | // Call handler right away so state gets updated with initial window size 25 | // Needed because we're using SSR first framework 26 | handleResize(); 27 | 28 | // Remove event listener on cleanup 29 | return () => window.removeEventListener("resize", handleResize); 30 | }, []); 31 | 32 | return windowSize; 33 | } 34 | 35 | export default function UseWindowSizeRoute() { 36 | const size = useWindowSize(); 37 | 38 | return ( 39 |
40 |

Window Size

41 |
42 | {size.width && size.height ? ( 43 |

44 | Width: {size.width}, Height: {size.height} 45 |

46 | ) : ( 47 |

Loading window size...

48 | )} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev --manual", 9 | "start": "remix-serve ./build/index.js", 10 | "typecheck": "tsc" 11 | }, 12 | "dependencies": { 13 | "@remix-run/css-bundle": "^2.0.0", 14 | "@remix-run/node": "^2.0.0", 15 | "@remix-run/react": "^2.0.0", 16 | "@remix-run/serve": "^2.0.0", 17 | "@vercel/analytics": "^1.0.2", 18 | "@vercel/remix": "^2.0.0", 19 | "isbot": "^3.6.8", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "remix-utils": "^7.5.0" 23 | }, 24 | "devDependencies": { 25 | "@remix-run/dev": "^2.0.0", 26 | "@remix-run/eslint-config": "^2.0.0", 27 | "@types/react": "^18.2.20", 28 | "@types/react-dom": "^18.2.7", 29 | "eslint": "^8.38.0", 30 | "tailwindcss": "^3.4.1", 31 | "typescript": "^5.1.6" 32 | }, 33 | "engines": { 34 | "node": ">=18.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigerabrodi/50-react-hooks/cb29c5f5bc5d469e7a8929c8ab8974a2bd1e9441/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // publicPath: "/build/", 7 | // serverBuildPath: "build/index.js", 8 | }; 9 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } satisfies Config; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------