Les 10 meilleurs hooks avec React Router 7 (anciennement Remix)

Dans cet article, je partage avec vous mes hooks préférés pour React Router 7 (anciennement Remix), que j’utilise dans quasiment tous mes projets.
1. useOptionalUser
C'est le hook que j'utilise le plus. Il permet de récupérer les informations optionnelles d'un utilisateur. Pour le faire fonctionner, il suffit de renvoyer un utilisateur dans le fichier app/routes/root.tsx
(fichier racine d'une application Remix). Toute donnée chargée dans root
est présente partout dans l'application (comme un contexte global).
1import { useRouteLoaderData } from "@remix-run/react";2import type { loader } from "app/root";34export const useOptionalUser = () => {5const data = useRouteLoaderData<typeof loader>("root");6if (data?.user) {7return data.user;8}9return null;10};1112export function useUser() {13const maybeUser = useOptionalUser();14if (!maybeUser) {15throw new Error(16"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.",17);18}19return maybeUser;20}
Comme on utilise le type générique du hook useRouteLoaderData
, ce hook renvoie une donnée typée. Merci Typescript !
Notez que l'on exporte également un hook
useUser
qui exploite le hookuseOptionalUser
, mais qui renvoie une erreur en cas d'utilisateur déconnecter. Cela permet de protéger les pages côté client en renvoyant une erreur en cas d'utilisateur non identifié.
2. useTailwindScreenSizes
J'utilise ce hook pour afficher un bandeau indiquant la taille d'écran active de TailwindCSS. Ces dimensions utilises les valeurs par défaut, qu'on retrouve dans la documentation officielle de TailwindCSS
1import React from "react";23export const useTailwindScreenSizes = ({4initialWidth = 1280,5}: {6initialWidth?: number;7}) => {8const [currentWidth, setCurrentWidth] = React.useState(initialWidth);910React.useEffect(() => {11if (typeof window === "undefined") return;1213const handleScreenResize = () => {14const width = window.innerWidth;15setCurrentWidth(width);16};17handleScreenResize();1819window.addEventListener("resize", handleScreenResize);20return () => {21window.removeEventListener("resize", handleScreenResize);22};23}, []);2425const getResponsive = () => {26if (currentWidth > 1536) {27return "2xl";28}2930if (currentWidth > 1280) {31return "xl";32}3334if (currentWidth > 1024) {35return "lg";36}3738if (currentWidth > 768) {39return "md";40}41if (currentWidth > 640) {42return "sm";43}4445return "very small";46};47return { currentWidth, getResponsive };48};
J'appelle ensuite ce hook dans un composant nommé DevTool
que je place de manière absolue. Dans le fichier root
par exemple.
1import { useIsClient } from "hooks/useIsClient.tsx";2import { useTailwindScreenSizes } from "hooks/useTailwindScreenSizes.tsx";34const Devtool = ({5initialScreenWidth = 1280,6}: {7initialScreenWidth?: number;8}) => {9const { currentWidth, getResponsive } = useTailwindScreenSizes({10initialWidth: initialScreenWidth,11});1213const isClient = useIsClient();14if (!isClient) return null;15return (16<div17className={`fixed bottom-0 left-0 z-20 flex flex-row gap-2 bg-yellow-500 font-normal text-black`}18>19<span>{currentWidth}</span>20<span>{getResponsive()}</span>21</div>22);23};2425export default Devtool;
3. useIsClient
Ce hook utilise-lui même un hook nommé useIsClient
, n'affichant rien côté serveur (on affiche ce composant côté client).
1import { useEffect, useState } from "react";23export function useIsClient() {4const [isClient, setClient] = useState(false);56useEffect(() => {7setClient(true);8}, []);910return isClient;11}
4. useGetUserPosition
Avez-vous déjà eu besoin de connaître la position géographique des utilisateurs de votre site ? On parle ici de latitude et longitude, qui permettent de calculer la distance kilométrique entre deux points.
C'est notamment utile pour géolocaliser l'utilisateur et lui proposer des données locales (c'est utilisé sur Leboncoin, Airbnb, Uber ...
J'ai utilisé ce hook pour la recherche d'adresse de GoodCollect.
1import { useState } from "react";2import { useToast } from "components/shadcn-ui/use-toast.ts";34export const useGetUserPosition = ({5successFn = () => {},6}: {7successFn?: ({ lat, lng }: { lat: number; lng: number }) => void;8}) => {9const [latLng, setLatLng] = useState<{10loading: boolean;11latitude: number | null;12longitude: number | null;13canGeoLocate: boolean;14}>({15loading: false,16latitude: null,17longitude: null,18canGeoLocate: true,19});2021const { toast } = useToast();2223const getUserPosition = () => {24if (!navigator.geolocation) {25setLatLng((oldValues) => ({26...oldValues,27canGeoLocate: false,28}));29return;30}31setLatLng((oldValues) => ({ ...oldValues, loading: true }));3233navigator.geolocation.getCurrentPosition(34(position) => {35const { latitude, longitude } = position.coords;3637setLatLng((oldValues) => ({38...oldValues,39latitude,40longitude,41loading: false,42}));4344successFn?.({ lat: latitude, lng: longitude });45},46(error) => {47if (error.PERMISSION_DENIED) {48toast({49description:50"Votre navigateur n'a pas les permissions pour vous localiser. Vous pouvez l'activer dans les paramètres de votre navigateur.",51variant: "error",52});53} else if (error.TIMEOUT) {54toast({55description:56"La géolocalisation a pris trop de temps. Veuillez réessayer.",57variant: "error",58});59} else if (error.POSITION_UNAVAILABLE) {60toast({61description:62"Votre position n'a pas pu être déterminée. Veuillez réessayer.",63variant: "error",64});65} else {66toast({67description: "Une erreur est survenue. Veuillez réessayer.",68variant: "error",69});70}71setLatLng((oldValues) => ({72...oldValues,73loading: false,74}));75},76{77timeout: 10_000,78enableHighAccuracy: true,79maximumAge: 60_000, // 60 seconds80},81);82};83return { getUserPosition, latLng };84};
Ce hook renvoie les valeurs suivantes :
- la latitude et longitude
- est-ce que l'utilisateur a accepté d'être géolocalisé / est-ce qu'il peut être géolicalisé
- est-ce que la géolocalisation est en cours (booléen loading)
Pour connaître la position, nous utilisons l'API native navigator.geolocation.getCurrentPosition
.
Ce hook utilise également le hook useToast
qui est entièrement facultatif, et qui permet d'afficher une alerte en cas d'erreur. Ce hook est fourni avec le composant toast de la librairie shadcn/ui.
6. useFileUpload
Ce hook permet d'afficher un fichier chargé depuis un input de type file
. Le code est à adadpté à votre cas d'utilisation. Le cas d'utilisation n'accepte que les fichiers images ou de type PDF.
Il met à disposition plusieurs propriétés pour réinitialiser l'input, afficher le fichier et savoir si c'est une image.
1import React from "react";23// Takes a file path and returns the file name and extension4const checkFilenameFormat = ({5fileName,6allowedFormats = ["jpg", "jpeg", "png", "heif", "avif", "webp", "pdf"],7}: {8fileName: string;9allowedFormats?: string[];10}) => {11const fileExtension = fileName.split(".").pop() || ""; // remove the dot1213// Only allow image files14const isFormatAllowed = allowedFormats.includes(fileExtension);15const isImage = fileExtension !== "pdf";16return { isImage, isFormatAllowed, fileExtension };17};1819export const useFileUpload = () => {20const [fileUpload, setFileUpload] = React.useState<File | null>(null);21const [documentPreview, setDocumentPreview] = React.useState<string | null>(22null,23);2425interface HtmlFormElement extends HTMLInputElement {26files: FileList;27}28const handleFileUpload = (e: React.ChangeEvent<HtmlFormElement>) => {29const [file] = e.target.files || [];30if (file) {31setFileUpload(file);32} else {33setFileUpload(null);34}35};3637const isFileImage = React.useMemo(() => {38if (!fileUpload) return null;39const fileFormat = checkFilenameFormat({40fileName: fileUpload?.name || "",41});42return fileFormat.isImage;43}, [fileUpload]);4445// This create a preview of the file, as a URL46React.useEffect(() => {47if (!fileUpload) return;48const objectUrl = URL.createObjectURL(fileUpload);49setDocumentPreview(objectUrl);5051return () => URL.revokeObjectURL(objectUrl);52}, [fileUpload]);5354const resetFileUpload = () => {55setFileUpload(null);56setDocumentPreview(null);57};5859return {60isFileImage,61documentPreview,62setFileUpload,63fileUpload,64handleFileUpload,65resetFileUpload,66};67};
Il suffit de l'utiliser avec input de type "file" pour le faire fonctionner.
1const { documentPreview, handleFileUpload, isFileImage, resetFileUpload } =2useFileUpload();34<input5accept="image/*,.pdf"6type="file"7onChange={handleFileUpload}8/>
7. useAddressCombobox
J'ai découvert ce hook en lisant l'article de Kent C. Dodds nommé Full Stack Components. Dedans, il nous explique la notion de composant full-stack : On déclare une route, mais au lieu de renvoyer une vue directement, nous exportons depuis cette route un hook, qui est typé et que nous pouvons ensuite utiliser partout.
Parfait pour un combobox d'adresse autocomplétée par exemple. Je l'ai adaptée pour utiliser l'API de Google (Places API) et obtenir l'auto-complétion des adresses.
Ce hook nécessite de télécharger deux librairies pour fonctionner :
- Downshift, une librairie que Kent a lui-même mis en place lors de sa mission à Paypal
- spin-delay pour afficher un spinner (suffisamment longtemps lors des petits chargements)
1import { useFetcher, useSearchParams } from "@remix-run/react";2import { useCombobox } from "downshift";3import { useEffect, useId, useState } from "react";4import { flushSync } from "react-dom";5import type { AddressComboboxType } from "routes/get-address";6import { useSpinDelay } from "spin-delay";78export const useAddressCombobox = ({9defaultAddress = null,10defaultPlaceId = null,11onAddressChange,12}: {13defaultAddress?: string | null;14defaultPlaceId?: string | null;15onAddressChange?: ({16address,17placeId,18}: {19address: string;20placeId: string;21}) => void;22}) => {23const addressFetcher = useFetcher<AddressComboboxType>();24const addressAvailabilityFetcher = useFetcher<typeof feedbackLoader>();2526const id = useId();27const addresses = addressFetcher.data?.addresses ?? [];28type AddressType = (typeof addresses)[number];29const [selectedAddress, setSelectedAddress] = useState<30null | undefined | AddressType31>({32address: defaultAddress ?? "",33id: defaultPlaceId ?? "",34});35// An errored address is an address that was suggested by Google, but that is not associated with a zipcode.36const [searchParams] = useSearchParams();3738const cb = useCombobox<AddressType>({39id,4041onSelectedItemChange: ({ selectedItem }) => {42setSelectedAddress(selectedItem);43},44items: addresses,45itemToString: (item) => (item ? item.address : ""),46defaultInputValue:47defaultAddress ??48searchParams.get("address") ??49"",50onInputValueChange: (changes) => {51addressFetcher.submit(52{ search: changes.inputValue ?? "" },53{ method: "get", action: "/get-address" },54);55},56});5758const busy = addressFetcher.state !== "idle";59const showSpinner = useSpinDelay(busy, {60delay: 150,61minDuration: 500,62});63const displayMenu = cb.isOpen && addresses.length > 0;646566return {67displayMenu,68showSpinner,69selectedAddress,70cb,71addressFetcher,72addresses,73};74};
8. useDateRanges
Pour les applications nécessitant de manipuler des dates, voici un hook permettant de les formatter sous forme de string
. Elles peuvent ensuite être sauvegardées dans l'URL (sur les sites de réservation par exemple).
Nous exploitons dans ce hook la librairie date-fns pour formatter la date.
12import {3parseStringToDate4} from "app/shared/dates.ts";5import {6differenceInDays7} from "date-fns";8import React, { useMemo } from "react";9import { useSearchParams } from "react-router";101112const getDateDifferenceInDays = ({13endDate,14startDate,15}: {16endDate: Date;17startDate: Date;18}) => {19// Always add more day, to include the current day20const dateDifferenceInDays = differenceInDays(endDate, startDate) + 1;2122return dateDifferenceInDays;23};2425export const useDateRanges = ({26defaultEndDate = null,27defaultStartDate = null,28defaultAdditionalDate = null,29minDate = null,30}: {31defaultStartDate?: string | null;32defaultEndDate?: string | null;33defaultAdditionalDate?: string | null;34minDate?: string | null;35}) => {36const [searchParams] = useSearchParams();3738const startDateParam = searchParams.get("startDate");39const endDateParam = searchParams.get("endDate");4041const minimumDate = new Date(minDate) || new Date()4243const [selectedDates, setSelectedDates] = React.useState<{44startDate: string;45endDate: string;46additionalDate?: string;47}>(() => {48return {49startDate: defaultStartDate ?? startDateParam ?? "",50endDate: defaultEndDate ?? endDateParam ?? "",51additionalDate: defaultAdditionalDate ?? "",52};53});5455const isOutated = React.useMemo(() => {56if (!selectedDates.startDate) return false;57const startDate = parseStringToDate(selectedDates.startDate);5859return startDate < minimumDate;60}, [selectedDates?.startDate, minimumDate]);6162const dateDifferenceInDays = useMemo(() => {63if (!selectedDates.startDate || !selectedDates.endDate) return 0;64return getDateDifferenceInDays({65endDate: parseStringToDate(selectedDates.endDate),66startDate: parseStringToDate(selectedDates.startDate),67});68}, [selectedDates.endDate, selectedDates.startDate]);6970return {71selectedDates,72setSelectedDates,73minimumDate,74isOutated,75dateDifferenceInDays,76};77};
9. useLocalStorage
Ce hook est très pratique pour sauvegarder des informations dans le localStorage
Le
localStorage
est une base de donnée présente directement dans le navigateur, qui permet par exemple de sauvegarder les informations de l'utilisateur comme son thème, s'il a accepté les cookies, ou toute information utile pour améliorer l'expérience utilisateur.
Cette version du hook nécessite de télécharger la librairie usehooks-ts. Il existe également des alternatives natives à React.
1npm install usehooks-ts
1import {2useCallback,3useEffect,4useState,5type Dispatch,6type SetStateAction,7} from "react";89import { useEventCallback, useEventListener } from "usehooks-ts";1011declare global {12interface WindowEventMap {13"local-storage": CustomEvent;14}15}1617type SetValue<T> = Dispatch<SetStateAction<T>>;1819export function useLocalStorage<T>(20key: string,21initialValue: T,22): [T, SetValue<T>] {23// Get from local storage then24// parse stored json or return initialValue25const readValue = useCallback((): T => {26// Prevent build error "window is undefined" but keeps working27if (typeof window === "undefined") {28return initialValue;29}3031try {32const item = window.localStorage.getItem(key);33return item ? (parseJSON(item) as T) : initialValue;34} catch (error) {35console.warn(`Error reading localStorage key “${key}”:`, error);36return initialValue;37}38}, [initialValue, key]);3940// State to store our value41// Pass initial state function to useState so logic is only executed once42const [storedValue, setStoredValue] = useState<T>(readValue);4344// Return a wrapped version of useState's setter function that ...45// ... persists the new value to localStorage.46const setValue: SetValue<T> = useEventCallback((value) => {47// Prevent build error "window is undefined" but keeps working48if (typeof window === "undefined") {49console.warn(50`Tried setting localStorage key “${key}” even though environment is not a client`,51);52}5354try {55// Allow value to be a function so we have the same API as useState56const newValue = value instanceof Function ? value(storedValue) : value;5758// Save to local storage59window.localStorage.setItem(key, JSON.stringify(newValue));6061// Save state62setStoredValue(newValue);6364// We dispatch a custom event so every useLocalStorage hook are notified65window.dispatchEvent(new Event("local-storage"));66} catch (error) {67console.warn(`Error setting localStorage key “${key}”:`, error);68}69});7071useEffect(() => {72setStoredValue(readValue());73// eslint-disable-next-line react-hooks/exhaustive-deps74}, []);7576const handleStorageChange = useCallback(77(event: StorageEvent | CustomEvent) => {78if ((event as StorageEvent)?.key && (event as StorageEvent).key !== key) {79return;80}81setStoredValue(readValue());82},83[key, readValue],84);8586// this only works for other documents, not the current one87useEventListener("storage", handleStorageChange);8889// this is a custom event, triggered in writeValueToLocalStorage90// See: useLocalStorage()91useEventListener("local-storage", handleStorageChange);9293return [storedValue, setValue];94}9596// A wrapper for "JSON.parse()"" to support "undefined" value97function parseJSON<T>(value: string | null): T | undefined {98try {99// @ts-ignore100return value === "undefined" ? undefined : JSON.parse(value ?? "");101} catch {102return undefined;103}104}
10. useQueryState
Ce hook s'utilise comme useState, mais va sauvegarder les valeurs comme paramètres d'URL.
Dans quel cas vas-tu utiliser les paramètres d'URL
?
- pour sauvegarder les filtres sur une application de réservation comme leboncoin ou Airbnb.
- pour la pagination d'un blog (numéro de page sélectionné, limite d'articles affichés).
- pour sauvegarder la valeur d'une barre de recherche (comme sur Google par exemple)
Pour ce faire, je te présente la librairie nuqs, qui vient exploiter les hooks natifs à Remix (comme useSearchParams)
L'exemple le plus simple se trouve sur la documentation de nuqs
12import { useQueryState } from 'nuqs'34export function Demo() {5const [name, setName] = useQueryState('name')6return (7<>8<input value={name || ''} onChange={e => setName(e.target.value)} />9<button onClick={() => setName(null)}>Clear</button>10<p>Hello, {name || 'anonymous visitor'}!</p>11</>12)13}
L'avantage de ce hook est sa simplicité d'utilisation. L'auteur de la librairie, François Best nous propose une expérience développeur d'exception.
Ses nombreux parsers
permettent de valider tout type de donnée (full typesafe) :
parseAsString
(les paramètres sont des string par défaut)parseAsInteger
pour les entiersparseAsFloat
pour les chiffres à 2 décimalesparseAsBoolean
pour les booléensparseAsStringLiteral
pour les string literals- et bien plus (les dates, les valeurs hexadécimales, les enums ...)
Le hook useQueryState prend comme premier argument le nom du paramètre d'URL
. Le deuxième argument est optionel, et peut soit être le parser
, soit un objet de configuration :
1import { useQueryState, parseAsInteger } from 'nuqs'23const [search] = useQueryState('search', { defaultValue: '' })4// ^? string56const [count] = useQueryState('count', parseAsInteger)7// ^? number | null -> no default value = nullable89const [count2] = useQueryState('count', parseAsInteger.withDefault(0))10// ^? number
J'ai de nombreux paramètres d'URL différents à gérer, comment faire ?
Utilise le hook useQueryStates (au pluriel), pour gérer un objet de plusieurs paramètres d'URL
.
1import { useQueryStates, parseAsFloat } from 'nuqs'23const [{ latitude, longitude }, setCoordinates] = useQueryStates(4{5// Use variable names that make sense in your codebase6latitude: parseAsFloat.withDefault(45.18),7longitude: parseAsFloat.withDefault(5.72)8},9{10urlKeys: {11// And remap them to shorter keys in the URL12latitude: 'lat',13longitude: 'lng'14}15}16)1718// No changes in the setter API, but the keys are remapped to:19// ?lat=45.18&lng=5.7220setCoordinates({21latitude: 45.18,22longitude: 5.7223})
J'espère que ces hooks vous seront utiles !