O que Todo Dev Deve Saber sobre Hooks com TypeScript: useRef, useReducer e Hooks Customizados Tipados Já leu

useRef: Acessando o DOM e Mantendo Valores Mutáveis O é um hook que permite acessar diretamente elementos do DOM e manter valores mutáveis que persistem entre renders sem causar re-renderizações. Muitos iniciantes confundem com , mas existe uma diferença fundamental: alterações em não disparam uma nova renderização do componente. Use quando precisa acessar propriedades nativas do DOM (como focar um input, reproduzir áudio ou video), armazenar um valor anterior, ou manter uma referência a um intervalo sem causar re-renders desnecessários. Em TypeScript, você deve tipar corretamente o elemento ou valor que está armazenando. Tipagem Básica de useRef Quando você cria uma referência para um elemento DOM, TypeScript precisa saber qual tipo de elemento é. Para um input, por exemplo, você usa : Note que tipamos como e inicializamos com . A verificação usa optional chaining porque pode ser inicialmente. useRef para Valores Primitivos e Objetos Além de referenciar DOM, você pode usar para armazenar valores que precisam persistir sem disparar

useRef: Acessando o DOM e Mantendo Valores Mutáveis

O useRef é um hook que permite acessar diretamente elementos do DOM e manter valores mutáveis que persistem entre renders sem causar re-renderizações. Muitos iniciantes confundem useRef com useState, mas existe uma diferença fundamental: alterações em useRef não disparam uma nova renderização do componente.

Use useRef quando precisa acessar propriedades nativas do DOM (como focar um input, reproduzir áudio ou video), armazenar um valor anterior, ou manter uma referência a um intervalo sem causar re-renders desnecessários. Em TypeScript, você deve tipar corretamente o elemento ou valor que está armazenando.

Tipagem Básica de useRef

Quando você cria uma referência para um elemento DOM, TypeScript precisa saber qual tipo de elemento é. Para um input, por exemplo, você usa HTMLInputElement:

import { useRef, useEffect } from 'react';

interface SearchFormProps {
  onSearch: (value: string) => void;
}

export function SearchForm({ onSearch }: SearchFormProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSearch = () => {
    if (inputRef.current) {
      onSearch(inputRef.current.value);
    }
  };

  useEffect(() => {
    // Focar automaticamente no input quando o componente monta
    inputRef.current?.focus();
  }, []);

  return (
    <div>
      <input
        ref={inputRef}
        type="text"
        placeholder="Digite sua busca..."
        onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
      />
      <button onClick={handleSearch}>Buscar</button>
    </div>
  );
}

Note que tipamos como HTMLInputElement e inicializamos com null. A verificação inputRef.current?.focus() usa optional chaining porque current pode ser null inicialmente.

useRef para Valores Primitivos e Objetos

Além de referenciar DOM, você pode usar useRef para armazenar valores que precisam persistir sem disparar re-renders. Um caso comum é contar quantas vezes um componente renderizou ou manter uma referência a um valor anterior:

import { useRef, useState, useEffect } from 'react';

interface VideoPlayerProps {
  videoUrl: string;
}

export function VideoPlayer({ videoUrl }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const playCountRef = useRef(0);

  useEffect(() => {
    playCountRef.current++;
    console.log(`Vídeo foi reproduzido ${playCountRef.current} vez(es)`);
  }, []);

  const togglePlay = () => {
    if (!videoRef.current) return;

    if (isPlaying) {
      videoRef.current.pause();
    } else {
      videoRef.current.play();
    }
    setIsPlaying(!isPlaying);
  };

  return (
    <div>
      <video
        ref={videoRef}
        src={videoUrl}
        width="400"
        onEnded={() => setIsPlaying(false)}
      />
      <button onClick={togglePlay}>
        {isPlaying ? 'Pausar' : 'Reproduzir'}
      </button>
    </div>
  );
}

Neste exemplo, playCountRef é do tipo RefObject<number> implicitamente. O contador aumenta, mas não causa re-render. A referência ao video permite chamar métodos nativos como play() e pause().


useReducer: Gerenciamento de Estado Complexo com TypeScript

O useReducer é ideal quando seu estado possui múltiplas sub-propriedades ou quando a lógica de transição entre estados é complexa. Diferentemente de useState, você define explicitamente quais ações podem ser despachadas e como o estado muda, tornando o código mais previsível e testável.

Em TypeScript, você tipará as ações usando tipos discriminados (tagged unions), o que oferece type-checking completo e autocomplete ao despachar ações. Isso evita erros em tempo de execução.

Estruturando Tipos para useReducer

A chave para um bom useReducer em TypeScript é criar tipos bem definidos para o estado e as ações. Use tipos discriminados para garantir que cada ação tenha propriedades específicas:

import { useReducer } from 'react';

// Estado
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  total: number;
  loading: boolean;
  error: string | null;
}

// Ações (tipo discriminado)
type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string } // id do item
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR_CART' }
  | { type: 'SET_LOADING'; payload: boolean }
  | { type: 'SET_ERROR'; payload: string };

const initialState: CartState = {
  items: [],
  total: 0,
  loading: false,
  error: null,
};

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find((item) => item.id === action.payload.id);

      if (existingItem) {
        return {
          ...state,
          items: state.items.map((item) =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + action.payload.quantity }
              : item
          ),
          total: state.total + action.payload.price * action.payload.quantity,
        };
      }

      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price * action.payload.quantity,
      };
    }

    case 'REMOVE_ITEM':
      const removedItem = state.items.find((item) => item.id === action.payload);
      return {
        ...state,
        items: state.items.filter((item) => item.id !== action.payload),
        total: removedItem
          ? state.total - removedItem.price * removedItem.quantity
          : state.total,
      };

    case 'UPDATE_QUANTITY': {
      const item = state.items.find((i) => i.id === action.payload.id);
      if (!item) return state;

      const difference = (action.payload.quantity - item.quantity) * item.price;
      return {
        ...state,
        items: state.items.map((i) =>
          i.id === action.payload.id
            ? { ...i, quantity: action.payload.quantity }
            : i
        ),
        total: state.total + difference,
      };
    }

    case 'CLEAR_CART':
      return { ...initialState };

    case 'SET_LOADING':
      return { ...state, loading: action.payload };

    case 'SET_ERROR':
      return { ...state, error: action.payload };

    default:
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

export function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const handleAddItem = () => {
    dispatch({
      type: 'ADD_ITEM',
      payload: {
        id: '1',
        name: 'Notebook',
        price: 3500,
        quantity: 1,
      },
    });
  };

  const handleRemoveItem = (id: string) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id });
  };

  const handleUpdateQuantity = (id: string, quantity: number) => {
    dispatch({
      type: 'UPDATE_QUANTITY',
      payload: { id, quantity },
    });
  };

  return (
    <div>
      <button onClick={handleAddItem}>Adicionar Item</button>
      <ul>
        {state.items.map((item) => (
          <li key={item.id}>
            {item.name} - R$ {item.price} x {item.quantity}
            <button onClick={() => handleRemoveItem(item.id)}>Remover</button>
            <input
              type="number"
              value={item.quantity}
              onChange={(e) =>
                handleUpdateQuantity(item.id, parseInt(e.target.value) || 1)
              }
            />
          </li>
        ))}
      </ul>
      <p>Total: R$ {state.total.toFixed(2)}</p>
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
    </div>
  );
}

O padrão never no final do switch garante que você tratou todas as ações possíveis. Se adicionar uma nova ação e esquecer de tratá-la, TypeScript avisará em tempo de compilação.

Vantagens do useReducer em Aplicações Escaláveis

Use useReducer quando o estado evolui de forma complexa ou quando múltiplos componentes precisam despachá-lo. Além da segurança de tipos, você ganha rastreabilidade: toda mudança de estado passa por uma ação específica, facilitando debug e testes.


Hooks Customizados Tipados: Reutilizando Lógica com Segurança

Hooks customizados são funções que encapsulam lógica reutilizável usando hooks nativos do React. Em TypeScript, você tipará os parâmetros e valores de retorno para garantir consistência em toda a aplicação. Um hook bem tipado é auto-documentado e previne erros.

Criando um Hook Customizado com Tipos Genéricos

Vamos criar um hook que encapsula a lógica de buscar dados de uma API com tratamento de erro e loading:

import { useState, useEffect, useCallback } from 'react';

interface UseFetchOptions {
  skip?: boolean;
  refetchInterval?: number;
}

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => Promise<void>;
}

export function useFetch<T>(
  url: string,
  options: UseFetchOptions = {}
): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = useCallback(async () => {
    if (options.skip) return;

    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const result = (await response.json()) as T;
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    } finally {
      setLoading(false);
    }
  }, [url, options.skip]);

  useEffect(() => {
    fetchData();

    if (options.refetchInterval) {
      const interval = setInterval(fetchData, options.refetchInterval);
      return () => clearInterval(interval);
    }
  }, [fetchData, options.refetchInterval]);

  return { data, loading, error, refetch: fetchData };
}

Este hook é genérico: você especifica o tipo dos dados esperados quando o usa. Veja como:

interface User {
  id: number;
  name: string;
  email: string;
}

export function UserList() {
  const { data: users, loading, error, refetch } = useFetch<User[]>(
    'https://api.example.com/users'
  );

  if (loading) return <p>Carregando usuários...</p>;
  if (error) return <p>Erro: {error.message}</p>;

  return (
    <div>
      <button onClick={refetch}>Recarregar</button>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

TypeScript infere automaticamente que users é do tipo User[] | null dentro do componente.

Hook Customizado com useReducer Integrado

Para lógica mais complexa, combine useReducer dentro de um hook customizado:

import { useReducer, useCallback } from 'react';

interface AsyncState<T> {
  status: 'idle' | 'pending' | 'success' | 'error';
  data: T | null;
  error: Error | null;
}

type AsyncAction<T> =
  | { type: 'PENDING' }
  | { type: 'SUCCESS'; payload: T }
  | { type: 'ERROR'; payload: Error }
  | { type: 'RESET' };

function asyncReducer<T>(
  state: AsyncState<T>,
  action: AsyncAction<T>
): AsyncState<T> {
  switch (action.type) {
    case 'PENDING':
      return { ...state, status: 'pending', error: null };
    case 'SUCCESS':
      return { status: 'success', data: action.payload, error: null };
    case 'ERROR':
      return { status: 'error', data: null, error: action.payload };
    case 'RESET':
      return { status: 'idle', data: null, error: null };
    default:
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

interface UseAsyncOptions<T> {
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

export function useAsync<T>(
  asyncFunction: () => Promise<T>,
  options: UseAsyncOptions<T> = {}
) {
  const initialState: AsyncState<T> = {
    status: 'idle',
    data: null,
    error: null,
  };

  const [state, dispatch] = useReducer(asyncReducer<T>, initialState);

  const execute = useCallback(async () => {
    dispatch({ type: 'PENDING' });
    try {
      const response = await asyncFunction();
      dispatch({ type: 'SUCCESS', payload: response });
      options.onSuccess?.(response);
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      dispatch({ type: 'ERROR', payload: error });
      options.onError?.(error);
    }
  }, [asyncFunction, options]);

  const reset = useCallback(() => {
    dispatch({ type: 'RESET' });
  }, []);

  return { ...state, execute, reset };
}

Uso prático:

interface Product {
  id: number;
  title: string;
  price: number;
}

export function ProductForm() {
  const { status, data, error, execute, reset } = useAsync<Product>(
    async () => {
      const response = await fetch('https://api.example.com/products/1');
      return response.json() as Promise<Product>;
    },
    {
      onSuccess: (product) => console.log('Produto carregado:', product),
      onError: (error) => console.error('Erro:', error.message),
    }
  );

  return (
    <div>
      <button onClick={() => execute()} disabled={status === 'pending'}>
        {status === 'pending' ? 'Carregando...' : 'Carregar Produto'}
      </button>
      {status === 'success' && data && (
        <div>
          <h2>{data.title}</h2>
          <p>R$ {data.price}</p>
        </div>
      )}
      {status === 'error' && error && <p>Erro: {error.message}</p>}
      {status === 'success' && (
        <button onClick={reset}>Limpar</button>
      )}
    </div>
  );
}

Boas Práticas para Hooks Customizados

Sempre retorne um objeto ou tupla tipada explicitamente. Documente quais estados o hook pode assumir e quais são os parâmetros obrigatórios. Mantenha o hook genérico quando possível (use generics), mas específico o suficiente para resolver um problema único. Um hook com múltiplas responsabilidades é difícil de manter.


Padrões Avançados: Integrando useRef, useReducer e Hooks Customizados

Quando você domina esses três conceitos, pode criar soluções sofisticadas. Um exemplo real é um formulário com validação persistente, histórico de mudanças e acesso direto a campos:

import { useRef, useReducer, ReactNode } from 'react';

interface FormField {
  value: string;
  error: string | null;
  touched: boolean;
}

interface FormState {
  fields: Record<string, FormField>;
  history: Record<string, FormField>[];
  isDirty: boolean;
}

type FormAction =
  | { type: 'SET_FIELD'; name: string; value: string }
  | { type: 'SET_ERROR'; name: string; error: string | null }
  | { type: 'TOUCH_FIELD'; name: string }
  | { type: 'RESET' }
  | { type: 'UNDO' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        fields: {
          ...state.fields,
          [action.name]: {
            ...state.fields[action.name],
            value: action.value,
          },
        },
        isDirty: true,
      };

    case 'SET_ERROR':
      return {
        ...state,
        fields: {
          ...state.fields,
          [action.name]: {
            ...state.fields[action.name],
            error: action.error,
          },
        },
      };

    case 'TOUCH_FIELD':
      return {
        ...state,
        fields: {
          ...state.fields,
          [action.name]: {
            ...state.fields[action.name],
            touched: true,
          },
        },
      };

    case 'RESET':
      return {
        fields: Object.fromEntries(
          Object.entries(state.fields).map(([key, field]) => [
            key,
            { ...field, value: '', error: null, touched: false },
          ])
        ),
        history: [],
        isDirty: false,
      };

    case 'UNDO':
      if (state.history.length === 0) return state;
      const previousFields = state.history[state.history.length - 1];
      return {
        ...state,
        fields: previousFields,
        history: state.history.slice(0, -1),
      };

    default:
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

interface UseFormOptions {
  initialValues: Record<string, string>;
  onSubmit: (values: Record<string, string>) => void;
  validate?: (name: string, value: string) => string | null;
}

export function useForm(options: UseFormOptions) {
  const formRef = useRef<HTMLFormElement>(null);
  const initialState: FormState = {
    fields: Object.fromEntries(
      Object.entries(options.initialValues).map(([key, value]) => [
        key,
        { value, error: null, touched: false },
      ])
    ),
    history: [],
    isDirty: false,
  };

  const [state, dispatch] = useReducer(formReducer, initialState);

  const setFieldValue = (name: string, value: string) => {
    dispatch({ type: 'SET_FIELD', name, value });

    if (options.validate) {
      const error = options.validate(name, value);
      dispatch({ type: 'SET_ERROR', name, error });
    }
  };

  const touchField = (name: string) => {
    dispatch({ type: 'TOUCH_FIELD', name });
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const values = Object.fromEntries(
      Object.entries(state.fields).map(([key, field]) => [key, field.value])
    );
    options.onSubmit(values);
  };

  const reset = () => {
    dispatch({ type: 'RESET' });
  };

  const undo = () => {
    dispatch({ type: 'UNDO' });
  };

  return {
    formRef,
    state,
    setFieldValue,
    touchField,
    handleSubmit,
    reset,
    undo,
    getFieldProps: (name: string) => ({
      value: state.fields[name].value,
      onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
        setFieldValue(name, e.target.value),
      onBlur: () => touchField(name),
    }),
  };
}

// Uso
export function LoginForm() {
  const form = useForm({
    initialValues: { email: '', password: '' },
    validate: (name, value) => {
      if (!value) return 'Campo obrigatório';
      if (name === 'email' && !value.includes('@')) return 'Email inválido';
      return null;
    },
    onSubmit: (values) => {
      console.log('Formulário submetido:', values);
    },
  });

  return (
    <form ref={form.formRef} onSubmit={form.handleSubmit}>
      <div>
        <label>Email</label>
        <input {...form.getFieldProps('email')} type="email" />
        {form.state.fields.email.touched && form.state.fields.email.error && (
          <span style={{ color: 'red' }}>{form.state.fields.email.error}</span>
        )}
      </div>

      <div>
        <label>Senha</label>
        <input {...form.getFieldProps('password')} type="password" />
        {form.state.fields.password.touched &&
          form.state.fields.password.error && (
            <span style={{ color: 'red' }}>
              {form.state.fields.password.error}
            </span>
          )}
      </div>

      <button type="submit">Entrar</button>
      <button type="button" onClick={form.reset}>
        Limpar
      </button>
      <button type="button" onClick={form.undo} disabled={!form.state.isDirty}>
        Desfazer
      </button>
    </form>
  );
}

Este exemplo mostra como os três conceitos trabalham juntos: useRef acessa o formulário, useReducer gerencia estado complexo com histórico, e o hook customizado encapsula tudo em uma interface limpa e tipada.


Conclusão

Você aprendeu que useRef acessa o DOM e mantém valores persistentes sem disparar re-renders, ideal para interações diretas com elementos e armazenamento de valores anteriores. Em TypeScript, a tipagem explícita (HTMLInputElement, HTMLVideoElement) previne erros de acesso a propriedades inválidas.

useReducer é superior a useState para estados complexos, especialmente com múltiplas transições. Usar tipos discriminados (tagged unions) oferece segurança de tipo completa: TypeScript garante que cada ação tem as propriedades corretas, e o padrão never no switch evita que você esqueça casos.

Hooks customizados tipados são o caminho para código reutilizável e escalável. Quando você combina useRef, useReducer e outros hooks em um hook customizado bem tipado, cria uma abstração poderosa que documenta a si mesma. Genéricos como <T> permitem flexibilidade sem perder segurança de tipo.


Referências


Artigos relacionados