Artigo original: How to Build Your Own React Hooks: A Step-by-Step Guide

Os hooks do React são uma customização essencial que permite que você adicione funcionalidades exclusivas às suas aplicações React.

Em vários casos, se você quer adicionar uma certa funcionalidade à sua aplicação, pode simplesmente adicionar uma biblioteca de terceiros que já existe para resolver o seu problema. Se tal biblioteca não existir, porém, o que fazer?

Como um desenvolvedor de React, é importante aprender os processos de criar hooks customizáveis para resolver problemas ou adicionar funcionalidades que faltam aos seus projetos.

Neste guia passo a passo, eu vou mostrar como criar os seus próprios hooks do React decompondo três hooks que fiz para as minhas aplicações e mostrando quais problemas eles foram criados para resolver.

1. useCopyToClipboard

Em uma versão anterior do meu site, reedbarger.com, eu permitia que usuários copiassem o código dos meus artigos com a ajuda de um pacote chamado react-copy-to-clipboard.

Quando um usuário passa o mouse sobre o trecho de código, clica no botão de copiar e o código é adicionado para a área de transferência, ele pode, então, colar e usar o código onde quiser.

fnmmit9fvxb4lejz3dcm

Ao invés de usar uma biblioteca de terceiros, no entanto, eu queria recriar essa funcionalidade com o meu próprio hook do React. Como qualquer outro hook customizável que crio, eu os coloco em uma pasta dedicada, geralmente chamada utils ou lib, especificamente, para funções que posso reutilizar na minha aplicação.

Colocaremos esse hook em um arquivo chamado useCopyToClipboard.js e criaremos uma função com o mesmo nome.

Existem várias maneiras de se copiar um texto para a área de transferência de um usuário. Prefiro utilizar uma biblioteca para isso, chamada copy-to-clipboard, que torna o processo mais confiável.

Ela exporta uma função que chamaremos de copy.

// utils/useCopyToClipboard.js
import React from "react";
import copy from "copy-to-clipboard";

export default function useCopyToClipboard() {}

Em seguida, criaremos uma função que será utilizada para copiar qualquer texto que deve ser adicionado a área de transferência de um usuário. Chamaremos essa função de handleCopy.

Como criar a função handleCopy

Nessa função, primeiramente, precisamos nos assegurar de que ela aceitará apenas parâmetros do tipo String ou Number. Colocaremos uma declaração com if-else, que garantirá que o tipo é uma String ou um Number. Caso contrário, imprimiremos um erro no console, que avisará o usuário de que ele não pode copiar qualquer outro tipo.

import React from "react";
import copy from "copy-to-clipboard";

export default function useCopyToClipboard() {
  const [isCopied, setCopied] = React.useState(false);

  function handleCopy(text) {
    if (typeof text === "string" || typeof text == "number") {
      // pode copiar
    } else {
      // não pode copiar
      console.error(
        `Não é permitido copiar o tipo ${typeof text} para a área de transferência; deve ser uma String ou um Number.`
      );
    }
  }
}

Em seguida, devemos converter o parâmetro text para String e passá-lo para a função copy. Depois, retornamos a função handleCopy dentro do hook para ser utilizada em qualquer lugar da nossa aplicação.

Normalmente, a função handleCopy será conectada a um evento onClick de um botão.

import React from "react";
import copy from "copy-to-clipboard";

export default function useCopyToClipboard() {
  function handleCopy(text) {
    if (typeof text === "string" || typeof text == "number") {
      copy(text.toString());
    } else {
      console.error(
        `Não é permitido copiar o tipo ${typeof text} para a área de transferência, deve ser uma String ou um Number.`
      );
    }
  }

  return handleCopy;
}

Além disso, queremos algum state que represente se o texto foi copiado ou não. Para criar esse comportamento, chamaremos useState no início do nosso hook e criaremos uma variável de estado isCopied, onde o método de atribuição se chamará setCopy.

Inicialmente, esse valor será false. Se o texto é copiado com sucesso, alteraremos a variável copy para true. Caso contrário, a alteraremos para false.

Finalmente, fazemos o hook retornar isCopied e handleCopy utilizando um array.

import React from "react";
import copy from "copy-to-clipboard";

export default function useCopyToClipboard(resetInterval = null) {
  const [isCopied, setCopied] = React.useState(false);

  function handleCopy(text) {
    if (typeof text === "string" || typeof text == "number") {
      copy(text.toString());
      setCopied(true);
    } else {
      setCopied(false);
      console.error(
        `Não é permitido copiar o tipo ${typeof text} para a área de transferência, deve ser uma String ou um Number.`
      );
    }
  }

  return [isCopied, handleCopy];
}

Como utilizar o hook useCopyToClipboard

Agora, podemos utilizar useCopyToClipboard dentro de qualquer componente que desejarmos.

Nesse caso, vou utilizá-lo com um componente de botão de copiar, que receberá o trecho de código que deve ser copiado.

Para fazer isso funcionar, tudo que precisamos é adicionar um evento de onClick ao botão. Nos parâmetros do nosso componente, recebemos código que será copiado para a área de transferência. Quando o código é copiado, podemos mostrar um ícone diferente indicando que o código foi copiado com sucesso.

import React from "react";
import ClipboardIcon from "../svg/ClipboardIcon";
import SuccessIcon from "../svg/SuccessIcon";
import useCopyToClipboard from "../utils/useCopyToClipboard";

function CopyButton({ code }) {
  const [isCopied, handleCopy] = useCopyToClipboard();

  return (
    <button onClick={() => handleCopy(code)}>
      {isCopied ? <SuccessIcon /> : <ClipboardIcon />}
    </button>
  );
}

Como adicionar um intervalo de reinicialização

Existe uma melhoria que podemos adicionar no nosso código. Na versão atual, isCopied sempre será true, e sempre veremos o ícone de sucesso:

pgpdz9f5xp7nr4twovsn

Se quisermos reinicializar o state do componente após alguns segundos, podemos passar um intervalo de tempo como parâmetro para useCopyToClipboard. Adicionaremos essa funcionalidade.

De volta ao nosso hook, podemos criar um parâmetro chamado resetInterval, que terá como valor padrão null, o que garantirá que o state não será reinicializado se nenhum argumento for passado para ele.

Adicionaremos também a função useEffect para implementar o seguinte comportamento: se o texto for copiado e tivermos um intervalo de reinicialização, alteraremos o valor de isCopied novamente para false após o intervalo utilizando a função setTimeout.

Além disso, precisamos remover esse comportamento se o nosso o componente que está utilizando o nosso hook de desmontar (unmount). Ou seja, no caso de não existir mais state ou componente para ser atualizado.

import React from "react";
import copy from "copy-to-clipboard";

export default function useCopyToClipboard(resetInterval = null) {
  const [isCopied, setCopied] = React.useState(false);

  const handleCopy = React.useCallback((text) => {
    if (typeof text === "string" || typeof text == "number") {
      copy(text.toString());
      setCopied(true);
    } else {
      setCopied(false);
      console.error(
        `Não é permitido copiar o tipo ${typeof text} para a área de transferência, deve ser uma String ou um Number.`
      );
    }
  }, []);

  React.useEffect(() => {
    let timeout;
    if (isCopied && resetInterval) {
      timeout = setTimeout(() => setCopied(false), resetInterval);
    }
    return () => {
      clearTimeout(timeout);
    };
  }, [isCopied, resetInterval]);

  return [isCopied, handleCopy];
}

Finalmente, a última melhoria que podemos fazer é envolver handleCopy em um hook useCallback para garantir que a função não seja recriada todas as vezes em que a tela for renderizada novamente.

Resultado

Com isso, temos o nosso hook que permite que o state seja reinicializado após um intervalo de tempo. Se passarmos um valor de tempo, veremos o resultado abaixo.

import React from "react";
import ClipboardIcon from "../svg/ClipboardIcon";
import SuccessIcon from "../svg/SuccessIcon";
import useCopyToClipboard from "../utils/useCopyToClipboard";

function CopyButton({ code }) {
  // isCopied é reinicializada após 3 segundos
  const [isCopied, handleCopy] = useCopyToClipboard(3000);

  return (
    <button onClick={() => handleCopy(code)}>
      {isCopied ? <SuccessIcon /> : <ClipboardIcon />}
    </button>
  );
}

2. hook usePageBottom

Em aplicações do React, muitas vezes, é importante saber quando o usuário rolou até o final da página.

Em aplicações que possuem "rolagem infinita", como o Instagram, por exemplo, quando o usuário chega no fim da página, a aplicação busca mais postagens.

4dav187wpkl46skhhjgh

Veremos como criar um hook usePageBottom para casos em que precisamos implementar uma página de rolagem infinita.

Começaremos criando um arquivo, usePageBottom.js na nossa pasta utils e adicionaremos uma função (hook) com o mesmo nome:

// utils/usePageBottom.js
import React from "react";

export default function usePageBottom() {}

Em seguida, precisamos calcular quando o usuário chega no final da página. Podemos determinar essa informação através do objeto window. Para acessar esse objeto, precisamos nos certificar de que o hook é chamado de um componente já montado (isMounted). Então, utilizaremos a função useEffect com um array vazio.

// utils/usePageBottom.js
import React from "react";

export default function usePageBottom() {
  React.useEffect(() => {}, []);
}

O usuário terá atingido o final da página quando o valor de window.innerHeight somado com document.scrollTop é igual ao valor de document.offsetHeight. Se esses dois valores são iguais, o resultado será true e o usuário terá atingido o final da página.

// utils/usePageBottom.js
import React from "react";

export default function usePageBottom() {
  React.useEffect(() => {
    window.innerHeight + document.documentElement.scrollTop === 
    document.documentElement.offsetHeight;
  }, []);
}

Salvaremos o resultado dessa expressão em uma variável isBottom, e atualizaremos uma variável de estado chamada bottom, que depois será retornada pelo nosso hook.

// utils/usePageBottom.js
import React from "react";

export default function usePageBottom() {
  const [bottom, setBottom] = React.useState(false);

  React.useEffect(() => {
    const isBottom =
      window.innerHeight + document.documentElement.scrollTop ===
      document.documentElement.offsetHeight;
    setBottom(isButton);
  }, []);

  return bottom;
}

Nosso código atual, no entanto, isso não funcionará. Por quê?

O problema está no fato que temos que calcular isBottom toda vez que o usuário está rolando a página. Por isso, precisamos receber eventos de rolagem da página utilizando window.addEventListener. Podemos criar uma função local que será chamada toda vez que o usuário rolar a página – a chamaremos de handleScroll.

// utils/usePageBottom.js
import React from "react";

export default function usePageBottom() {
  const [bottom, setBottom] = React.useState(false);

  React.useEffect(() => {
    function handleScroll() {
      const isBottom =
        window.innerHeight + document.documentElement.scrollTop 
        === document.documentElement.offsetHeight;
      setBottom(isButton);
    }
    window.addEventListener("scroll", handleScroll);
  }, []);

  return bottom;
}

Finalmente, como temos um "event listener" que atualizará o state, precisamos tratar o caso de o usuário sair da página e o nosso componente ser removido. Precisamos remover esse "event listener" que adicionamos para evitar que uma variável de estado que não existe mais seja alterada.

Podemos fazer isso retornando a função window.removeEventListener, onde passamos uma referência para a função handleScroll. Agora, tudo certo!

// utils/usePageBottom.js
import React from "react";

export default function usePageBottom() {
  const [bottom, setBottom] = React.useState(false);

  React.useEffect(() => {
    function handleScroll() {
      const isBottom =
        window.innerHeight + document.documentElement.scrollTop 
        === document.documentElement.offsetHeight;
      setBottom(isButton);
    }
    window.addEventListener("scroll", handleScroll);
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);

  return bottom;
}

Podemos simplesmente chamar esse código em qualquer função em que queremos saber se o final da página foi atingido ou não.

Em um dos meus sites do Gatsby, eu tenho um cabeçalho. Conforme diminuo o tamanho da página, eu quero mostrar menos links para o usuário.

kxbnn3jmwjarkc8zrpbm

Para fazermos isso, podemos utilizar uma Media Query (CSS) ou podemos utilizar um hook do React para nos retornar o tamanho da página e exibir ou esconder os links no código JSX.

Anteriormente, eu estava utilizando um hook de uma biblioteca chamada react-use. Ao invés de utilizar uma biblioteca de terceiros, eu decidi criar meu próprio hook para retornar as dimensões da página, tanto largura quanto altura. Chamei esse hook de useWindowSize.

Como criar o hook

Primeiramente, criaremos um arquivo .js na nossa pasta utils. Vamos chamá-lo de useWindowSize. Importaremos o React (para utilizar hooks) e exportaremos o hook atual.

// utils/useWindowSize.js

import React from "react";

export default function useWindowSize() {}

Como estou usando esse hook em um site do Gatsby.js, que é renderizado no servidor, eu preciso obter o tamanho da janela. Porém, talvez não tenhamos acesso ao tamanho da janela, pois estamos renderizando a página no servidor.

Para ter certeza de que não estamos no servidor, podemos verificar se o tipo de window não é igual a undefined.

Nesse caso, podemos retornar uma largura e altura para um navegador padrão. Retornaremos 1200 e 800 em um objeto.

// utils/useWindowSize.js

import React from "react";

export default function useWindowSize() {
  if (typeof window !== "undefined") {
    return { width: 1200, height: 800 };
  }
}

Como obter a largura e altura da janela

Quando estamos no client (navegador) e podemos utilizar a janela (window), temos a opção de usar o hook useEffect para interagir com a janela. Incluiremos um array vazio de dependências para ter certeza que o nosso hook é chamado apenas quando o componente pai está montado (mounted).

Para descobrir a largura e altura da janela, podemos adicionar um event listener para escutar por eventos do tipo resize. Toda vez que o tamanho da janela mudar, podemos atualizar uma parte do state (criado com useState), que chamaremos de windowSize, chamaremos o método de atualização de setWindowSize.

// utils/useWindowSize.js

import React from "react";

export default function useWindowSize() {
  if (typeof window !== "undefined") {
    return { width: 1200, height: 800 };
  }

  const [windowSize, setWindowSize] = React.useState();

  React.useEffect(() => {
    window.addEventListener("resize", () => {
      setWindowSize({ width: window.innerWidth, height: window.innerHeight });
    });
  }, []);
}

Quando a janela é redimensionada, o nosso event listener será chamado e o state de windowSize será atualizado com as novas dimensões. Para realizar isso, atualizamos a largura para window.innerWidth e a altura para window.innerHeight.

Como adicionar suporte a Server Side Rendering

No entanto, o nosso código atual não funcionará. Isso acontece porque existe uma regra dos hooks que os impedem de serem chamados condicionalmente. Como resultado, não podemos ter uma condicional nos métodos useState ou useEffect antes de eles serem chamados.

Então, para contornar esse problema, definiremos o valor inicial de useState condicionalmente. Criaremos uma variável chamada isSSR, que ficará responsável por verificar se a janela não é igual a uma string undefined.

Depois, utilizaremos um ternário para definir a largura e altura e checando primeiramente se estamos no servidor ou no navegador. Se estivermos no servidor, usaremos um valor padrão. Caso contrário, usaremos window.innerWidth e window.innerHeight.

// utils/useWindowSize.js

import React from "react";

export default function useWindowSize() {
  // if (typeof window !== "undefined") {
  // return { width: 1200, height: 800 };
  // }
  const isSSR = typeof window !== "undefined";
  const [windowSize, setWindowSize] = React.useState({
    width: isSSR ? 1200 : window.innerWidth,
    height: isSSR ? 800 : window.innerHeight,
  });

  React.useEffect(() => {
    window.addEventListener("resize", () => {
      setWindowSize({ width: window.innerWidth, height: window.innerHeight });
    });
  }, []);
}

Finalmente, precisamos pensar em como os nossos componentes desmontarão (unmount). O que precisamos fazer? É necessária a remoção do listener de redimensionamento.

Como remover o event listener de redimensionamento

Podemos fazer isso retornando uma função no método useEffect. Removeremos o listener com window.removeEventListener.

// utils/useWindowSize.js

import React from "react";

export default function useWindowSize() {
  // if (typeof window !== "undefined") {
  // return { width: 1200, height: 800 };
  // }
  const isSSR = typeof window !== "undefined";
  const [windowSize, setWindowSize] = React.useState({
    width: isSSR ? 1200 : window.innerWidth,
    height: isSSR ? 800 : window.innerHeight,
  });

  React.useEffect(() => {
    window.addEventListener("resize", () => {
      setWindowSize({ width: window.innerWidth, height: window.innerHeight });
    });

    return () => {
      window.removeEventListener("resize", () => {
        setWindowSize({ width: window.innerWidth, height: window.innerHeight });
      });
    };
  }, []);
}

Precisamos, contudo, de uma referência para a mesma função e não de dois métodos diferentes como temos aqui. Para fazer isso, criaremos uma função de callback para os dois listeners, chamada changeWindowSize.

Para encerrar, no final do hook, retornaremos o state de windowSize.

// utils/useWindowSize.js

import React from "react";

export default function useWindowSize() {
  const isSSR = typeof window !== "undefined";
  const [windowSize, setWindowSize] = React.useState({
    width: isSSR ? 1200 : window.innerWidth,
    height: isSSR ? 800 : window.innerHeight,
  });

  function changeWindowSize() {
    setWindowSize({ width: window.innerWidth, height: window.innerHeight });
  }

  React.useEffect(() => {
    window.addEventListener("resize", changeWindowSize);

    return () => {
      window.removeEventListener("resize", changeWindowSize);
    };
  }, []);

  return windowSize;
}

Resultado

Para utilizar o hook, precisamos importá-lo onde ele é requerido, chamá-lo e utilizar a largura da janela retornada quando queremos esconder ou mostrar certos elementos na tela.

No exemplo abaixo, utilizaremos 500px como a nossa condição. Aqui, queremos esconder todos os links e mostrar apenas o botão "Join Now":

// components/StickyHeader.js

import React from "react";
import useWindowSize from "../utils/useWindowSize";

function StickyHeader() {
  const { width } = useWindowSize();

  return (
    <div>
      {/* visible only when window greater than 500px */}
      {width > 500 && (
        <>
          <div onClick={onTestimonialsClick} role="button">
            <span>Testimonials</span>
          </div>
          <div onClick={onPriceClick} role="button">
            <span>Price</span>
          </div>
          <div>
            <span onClick={onQuestionClick} role="button">
              Question?
            </span>
          </div>
        </>
      )}
      {/* visible at any window size */}
      <div>
        <span className="primary-button" onClick={onPriceClick} role="button">
          Join Now
        </span>
      </div>
    </div>
  );
}

Esse hook funcionará em qualquer aplicação do React renderizada no servidor, como as que usam Gatsby e Next.js.

3. hook useDeviceDetect

Estou criando uma landing page para um curso e há um comportamento muito estranho quando a página é acessada por dispositivos móveis. Em computadores de mesa, está tudo certo.

Quando uso um dispositivo móvel, porém, tudo parece fora de lugar.

n69a3h184fhniah3g1z8

Descobri que o problema está relacionado com uma biblioteca chamada react-device-detect, que eu estava utilizando para detectar se os usuários acessavam o site de um dispositivo móvel ou não. Em caso afirmativo, o cabeçalho da página deve ser escondido.

// templates/course.js
import React from "react";
import { isMobile } from "react-device-detect";

function Course() {
  return (
    <>
      <SEO />
      {!isMobile && <StickyHeader {...courseData} />}
      {/* more components... */}
    </>
  );
}

O problema é que essa biblioteca não oferece suporte para renderização no servidor (em inglês, server-side rendering), que é o que o Gatsby usa por padrão. Então, precisei criar a minha própria solução para identificar quando o usuário acessa o site de um dispositivo móvel. Para isso, eu decidi criar um hook customizável chamado useDeviceDetect.

Como criei o hook

Criei um arquivo separado para esse hook na pasta utils, chamado useDeviceDetect.js. Como hooks são simples funções do JavaScript que podem ser reaproveitadas, eu criei uma função chamada useDeviceDetect e importei as bibliotecas do React.

// utils/useDeviceDetect.js
import React from "react";

export default function useDeviceDetect() {}

Como descobrir o User Agent através da window

Um jeito de descobrir informações sobre o dispositivo do usuário é através da propriedade userAgent (que está no objeto navigator, dentro de window).

Como interagir com a API window pode ser considerado um efeito colateral, precisamos acessar a propriedade "user agent" dentro do hook useEffect.

// utils/useDeviceDetect.js
import React from "react";

export default function useDeviceDetect() {
  React.useEffect(() => {
    console.log(`Dispositivo do usuário: ${window.navigator.userAgent}`);
    // também pode ser escrito como 'navigator.userAgent'
  }, []);
}

Quando o componente for montado (mounted), podemos usar typeof navigator para determinar se estamos no navegador ou no servidor. Se estivermos no servidor, não teremos acesso ao window. Nesse caso, typeof navigator será igual à String undefined. Caso contrário, estamos no client e teremos acesso à propriedade "user agent".

Podemos expressar a lógica acima utilizando um ternário para obter a propriedade userAgent.

// utils/useDeviceDetect.js
import React from "react";

export default function useDeviceDetect() {
  React.useEffect(() => {
    const userAgent =
      typeof navigator === "undefined" ? "" : navigator.userAgent;
  }, []);
}

Como checar se userAgent é um dispositivo móvel

userAgent é uma String que terá um dos valores abaixo caso o usuário esteja em um dispositivo móvel:

Android, BlackBerry, iPhone, iPad, iPod, Opera Mini, IEMobile, ou WPDesktop.

Tudo que precisamos fazer é utilizar a propriedade userAgent e o método .match() com uma expressão regular para checar se o valor é alguma das opções acima. Guardaremos o resultado em uma variável local chamada mobile.

Armazenaremos esse resultado no estado utilizando o hook useState, que terá o valor inicial de false. Para isso, criamos a variável de estado isMobile e o método de atribuição setMobile.

// utils/useDeviceDetect.js
import React from "react";

export default function useDeviceDetect() {
  const [isMobile, setMobile] = React.useState(false);

  React.useEffect(() => {
    const userAgent =
      typeof window.navigator === "undefined" ? "" : navigator.userAgent;
    const mobile = Boolean(
      userAgent.match(
        /Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i
      )
    );
    setMobile(mobile);
  }, []);
}

Então, quando definirmos o valor de mobile, vamos guardá-lo no state. Finalmente, retornaremos um objeto no caso de precisarmos adicionar novas informações e funcionalidades à esse hook.

No objeto de retorno, adicionaremos isMobile como uma propriedade e seu valor.

// utils/useDeviceDetect.js
import React from "react";

export default function useDeviceDetect() {
  const [isMobile, setMobile] = React.useState(false);

  React.useEffect(() => {
    const userAgent =
      typeof window.navigator === "undefined" ? "" : navigator.userAgent;
    const mobile = Boolean(
      userAgent.match(
        /Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i
      )
    );
    setMobile(mobile);
  }, []);

  return { isMobile };
}

Resultado

De volta a nossa landing page, podemos executar o hook e utilizar o valor da propriedade isMobile onde quisermos.

// templates/course.js
import React from "react";
import useDeviceDetect from "../utils/useDeviceDetect";

function Course() {
  const { isMobile } = useDeviceDetect();

  return (
    <>
      <SEO />
      {!isMobile && <StickyHeader {...courseData} />}
      {/* more components... */}
    </>
  );
}

Conclusão

Como eu tentei demonstrar nos exemplos acima, os hooks do React podem nos fornecer as ferramentas necessárias para consertar os problemas quando bibliotecas externas falharem.

Espero que este guia tenha dado a você uma ideia melhor de como criar os seus próprios hooks em React. Fique à vontade para usar qualquer um dos hooks acima nos seus projetos ou como inspiração para criar outros hooks.

Torne-se um desenvolvedor de React profissional

React é difícil. Você não precisa aprendê-lo sozinho.

Coloquei tudo o que sei sobre React em um único curso, para ajudar você a alcançar seus objetivos em tempo recorde:

Apresento a vocês: The React Bootcamp (em inglês)

react-bootcamp-cta-alt-1

É o curso que eu gostaria de ter feito quando comecei a aprender React.