Tipando state e refs
TL;DR
useStateinfere de primitivos mas precisa anotar comnull/[]/{}. Em React 19,useReffoi unificado: todouseRefretornaRefObject<T>(com.currentmutável e nullable), e o argumento é obrigatório. A distinção legacyRefObjectvsMutableRefObjectdesapareceu. Callback refs ((node) => void) cobrem casos onde o nó precisa ser observado, não armazenado.
O que é
Em React há duas formas canônicas de armazenar valores que sobrevivem entre renders, e elas resolvem problemas distintos:
- State (
useState) — valor que dispara re-render quando muda. É a fonte da UI: o componente pinta o valor atual de state em JSX, e mudar o valor reativa o pipeline de render. TypeScript infere o tipo do initializer quando ele “fala por si” (useState(0)→number); quando não fala (null,[],{}), exige anotação explícita. - Ref (
useRef) — container persistente entre renders que não dispara re-render. É escape hatch: serve para guardar coisas que o React não precisa observar — um nó DOM para focar, um timer ID, um contador imperativo, uma instância de classe. Em React 19, o comportamento foi unificado e a tipagem ficou consistente.
Na prática, refs aparecem em duas categorias:
- DOM ref — você passa para JSX (
<input ref={ref}>), e o React preencheref.currentcom o nó DOM correspondente após o mount. Tipo canônico:RefObject<HTMLInputElement>(ou oHTMLElementcorrespondente à tag). - Mutable ref — controle do dev. Nada de DOM: você guarda um ID de timer, um contador que não precisa pintar, uma instância de objeto pesado. O initializer é o valor inicial, e você muta
.currentà vontade dentro de handlers e effects.
A distinção é semântica, não sintática — em React 19 ambas usam useRef<T>(initial) com a mesma forma de retorno. O que muda é quem escreve em .current: o React (DOM ref) ou o seu código (mutable ref).
Por que importa
Antes do React 19, useRef tinha overloads diferentes que confundiam: useRef<HTMLDivElement>(null) retornava RefObject<T> com .current read-only (porque era o React que ia escrever ali), enquanto useRef<number>(0) retornava MutableRefObject<T> com .current mutável. Era a mesma função, com retornos diferentes conforme você passasse null literal ou um valor concreto. O resultado: erros de tipo confusos quando o dev tentava mutar um ref de DOM (legítimo em alguns casos) ou esquecia que o ref mutável não permitia null na inicialização sem virar MutableRefObject<T | null>.
React 19 unificou: todo useRef<T>(initial) retorna RefObject<T> com .current: T mutável. O argumento agora é obrigatório — useRef() sem argumento dá erro de tipo, e a forma de inicializar “vazio” é passar explícito (useRef<Timer | null>(null) ou useRef<number | undefined>(undefined)). A simplificação reduz a superfície de surpresas, mas em codebases mistos (libs com @types/react@18 ainda presentes) os dois mundos convivem — entender qual versão está em jogo evita perda de tempo decifrando erros que mudaram de forma.
Já em useState, a frustração canônica é useState(null). O TS infere null literal (não null | T), então setUser(novoUsuario) falha com Type 'User' is not assignable to type 'null'. A correção é anotar o generic — useState<User | null>(null) — e essa armadilha aparece tantas vezes na prática que vira instinto. Mesma lógica para useState([]) (infere never[] em strict mode) e useState({}) (infere {} sem props conhecidas).
Como funciona
Sample 1 — useState<T> quando o initializer não diz o tipo
import { useState } from 'react';
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [form, setForm] = useState<Partial<FormData>>({});
const [cache, setCache] = useState<Map<string, User>>(new Map());
// Inferidos (não precisam de generic):
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [enabled, setEnabled] = useState(false); // booleanA regra é a mesma de 02: se o initializer fala por si, deixe inferir; se ele é uma “ausência representada como valor” (null, [], {}, new Map()), anote o generic. O caso de Partial<FormData> é interessante — o tipo da FormData completa é o objetivo, mas o initializer começa parcial; Partial documenta isso no tipo.
Sample 2 — useRef para DOM (React 19)
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
// inputRef: RefObject<HTMLInputElement>
// inputRef.current: HTMLInputElement | null
useEffect(() => {
inputRef.current?.focus(); // narrow com optional chaining
}, []);
return <input ref={inputRef} />;
}O generic <HTMLInputElement> informa ao TS qual tipo de nó DOM esse ref vai receber. O initializer null é obrigatório — antes do mount, o React ainda não preencheu ref.current, então o tipo correto é HTMLInputElement | null. O optional chaining (inputRef.current?.focus()) faz o narrow seguro: se current for null, a chamada simplesmente não acontece.
Sample 3 — useRef para mutable (React 19, exige argumento)
import { useRef } from 'react';
function Stopwatch() {
const counter = useRef(0); // RefObject<number>, .current sempre mutável
const timerId = useRef<number | null>(null); // RefObject<number | null>
function start() {
timerId.current = window.setInterval(() => {
counter.current++;
}, 1000);
}
function stop() {
if (timerId.current !== null) {
clearInterval(timerId.current);
timerId.current = null;
}
}
return <button onClick={start}>start</button>;
}
// useRef() sem argumento — ERRO em React 19:
// const x = useRef();
// ~~~~~~~~ Expected 1 arguments, but got 0.
// Para ref "vazio", inicialize explícito:
// const x = useRef<Foo | undefined>(undefined);O contador é incrementado dentro do callback de setInterval, e como ref não dispara re-render, esse valor é o “estado paralelo” do componente — útil quando o dev precisa de algo persistente entre renders mas que não deve causar repaint. Note que o TS aqui infere RefObject<number> para counter (initializer 0 fala por si) e RefObject<number | null> para timerId (anotado explícito porque o valor inicial é null). Em ambos os casos, .current é mutável — não há mais a divisão RefObject vs MutableRefObject da era pré-19.
Sample 4 — Callback ref (quando precisa de lógica no mount/unmount)
import { useCallback } from 'react';
function ObservedDiv() {
const setRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
// executar lógica quando o nó é mounted
const observer = new IntersectionObserver(([entry]) => {
console.log('intersection:', entry.isIntersecting);
});
observer.observe(node);
}
// node é null quando unmounted — momento de cleanup, se preciso
}, []);
return <div ref={setRef}>...</div>;
}Callback ref é uma alternativa a useRef quando o dev precisa observar o nó no momento exato do mount/unmount, não apenas guardar uma referência para usar depois. O React chama o callback com o nó DOM logo após o mount e com null no unmount. É o pattern certo para casos de IntersectionObserver, ResizeObserver, ou qualquer setup de side effect que dependa do nó concreto. Em React 19, o callback pode opcionalmente retornar uma cleanup function — o tipo TS rejeita retorno implícito que não seja uma função (ex: ref={node => (instance = node)} precisa virar ref={node => { instance = node; }}).
Sample 5 — useImperativeHandle tipado para expor API customizada
import { useRef, useImperativeHandle, forwardRef } from 'react';
type InputHandle = {
focus: () => void;
clear: () => void;
};
const FancyInput = forwardRef<InputHandle, { placeholder?: string }>(
function FancyInput({ placeholder }, ref) {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => innerRef.current?.focus(),
clear: () => {
if (innerRef.current) innerRef.current.value = '';
},
}), []);
return <input ref={innerRef} placeholder={placeholder} />;
}
);
// Uso:
const handleRef = useRef<InputHandle>(null);
<FancyInput ref={handleRef} />;
handleRef.current?.focus();useImperativeHandle permite que um componente filho exponha uma API customizada (não o nó DOM bruto) para o pai via ref. Os generics em forwardRef<InputHandle, Props> declaram: “o ref aponta para InputHandle, e as props têm shape { placeholder?: string }”. O resultado é um componente onde o pai recebe RefObject<InputHandle> e pode chamar handleRef.current?.focus() ou handleRef.current?.clear() sem saber nada da estrutura DOM interna.
Em React 19, forwardRef é opcional: ref virou prop normal em function components, então o mesmo componente pode ser escrito sem o wrapper:
function FancyInput({
placeholder,
ref,
}: {
placeholder?: string;
ref?: React.Ref<InputHandle>;
}) {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({ /* ... */ }), []);
return <input ref={innerRef} placeholder={placeholder} />;
}A versão sem forwardRef é o caminho idiomático em React 19 para componentes novos — forwardRef continua exportado para compatibilidade, mas a documentação oficial sinaliza depreciação futura. useImperativeHandle permanece relevante: é o hook que define a API exposta, independente de como o ref chega ao componente. Aprofundamento de polimorfismo de ref em nota 13.
Na prática
Pattern comum no ecossistema React: hooks customizados que retornam um ref já configurado, escondendo o setup interno do consumidor. O exemplo canônico é um useFocusOnMount, que abstrai o boilerplate de useRef + useEffect em um único valor:
import { useRef, useEffect, type RefObject } from 'react';
function useFocusOnMount<T extends HTMLElement>(): RefObject<T | null> {
const ref = useRef<T>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return ref;
}
// Uso:
function LoginForm() {
const emailRef = useFocusOnMount<HTMLInputElement>();
return <input ref={emailRef} type="email" />;
}O hook abstrai três decisões em uma assinatura: o tipo do nó (via generic <T extends HTMLElement>), o initializer (null, obrigatório em React 19), e o efeito de focar no mount. O consumidor passa apenas o tipo concreto (<HTMLInputElement>) e usa o ref retornado como qualquer DOM ref. Esse é o pattern observado em libs como react-use, usehooks-ts e similares: hooks pequenos que encapsulam setup de ref + effect e retornam o ref pronto para uso.
A regra prática derivada: quando o mesmo padrão de useRef + useEffect aparece em mais de dois componentes do app, vale extrair para hook customizado. O return type explícito (RefObject<T | null>) torna a API contratual — útil principalmente quando o hook vira parte da camada compartilhada entre features. Aprofundamento em 07 - Tipando hooks customizados.
Armadilhas
-
useRef<T>(null!)é anti-pattern. O!força o TS a tratar o initializer como não-null, mas isso transfere o ônus de checagem para todo call site — se algum acesso aref.currentacontecer antes do mount (em SSR, ou em código que executa fora deuseEffect), o tipo mente. PrefirauseRef<T>(null)e tratarnullno consumer com optional chaining (ref.current?.focus()) ou guard explícito (if (ref.current) ...). -
Em React 18 e anterior,
useRef<HTMLDivElement>(null)retornavaRefObjectcom.currentread-only; em React 19 todos os refs são mutáveis. A diferença importa em codebases mistos onde libs ainda usam@types/react@18em algum sub-pacote — um ref que parecia imutável passa a aceitar mutação direta, e código que dependia da imutabilidade como invariante perde essa garantia. Verifique a versão do@types/reactna lockfile antes de assumir o comportamento. -
Confundir DOM ref com mutable ref. DOM ref é o que você passa para JSX (
<input ref={ref}>); o React escreve em.currentno mount. Mutable ref é o que você muta no seu próprio código (counter.current++); o React não toca em.current. Sintoma de confusão: passar um ref de timer ID para JSX (<div ref={timerRef}>), o que faz o React tentar escrever um nó DOM por cima do número e quebra o invariante esperado. Refs DOM tipam comHTMLElemente descendentes; refs mutáveis tipam com o que faz sentido para o valor (number, Map, instância de classe). -
useState(null)inferenullliteral. Sintoma:setUser(novoUser)falha comType 'User' is not assignable to type 'null'. Sempre que o initializer énull, anote:useState<User | null>(null). Mesma armadilha emuseState([])(inferenever[]) euseState({})(infere{}sem props), discutida em 01 e 02. -
Acessar
ref.currentdurante o render. Refs não são garantidos antes do mount: em DOM refs,ref.currenténullaté o React preencher, e ler o valor durante o render do próprio componente que monta o nó dánullmesmo. A regra documentada é acessarref.currentapenas emuseEffect, event handlers ou callbacks assíncronos — ou seja, em código que roda depois da fase de render. Sintoma de violação:Cannot read property 'focus' of nullem runtime, mesmo o JSX parecendo correto.
Em entrevista
“In React 19,
useRefwas unified — it always returnsRefObject<T>with a mutable, nullable.current, and the argument is required. Before React 19, there were two variants —RefObjectfor DOM refs andMutableRefObjectfor mutable values — which confused developers. Now there’s one shape. For DOM, pass the ref to JSX and accessref.currentafter mount. For mutable values that survive re-renders without triggering them, treat the ref as a side container. For exposing imperative APIs to parents,useImperativeHandlepaired withforwardRefis still the pattern, althoughrefis now a regular prop in function components, soforwardRefis increasingly optional.”
Vocabulário-chave: RefObject, callback ref, imperative handle, initial value, DOM node.
Pergunta típica de senior interview: “What’s the difference between useState and useRef, and when do you reach for each?” — resposta defensiva: useState for values that should trigger re-renders when they change — anything the UI reads from. useRef for values that need to persist across renders without triggering them — DOM nodes, timer IDs, imperative counters. The TypeScript signal is the same: useState annotation is needed when the initializer is null, [], or {}; useRef in React 19 always returns RefObject<T> with a mutable .current, and the initializer argument is required.
Veja também
- 01 - A tripla inferência - props, state, hooks
- 06 - Tipando event handlers
- 13 - Polymorphic components com as prop
- React — seção “Hooks essenciais”