Pular para o conteúdo principal

Loading padronizado em formulários — Button, FormFieldSkeleton, TriggerStatus

Antes destes componentes, cada formulário resolvia o estado de "carregando" do seu jeito: spinner montado à mão dentro do botão, texto "Carregando..." trocado no placeholder do Select, ou simplesmente disabled sem nenhum feedback visual. Os três componentes abaixo cobrem os casos mais comuns — vivem em packages/ui/src/shadcn/.

Button com loading​

O Button aceita uma prop loading opcional: quando true, mostra um spinner (@kit/ui/spinner) antes do conteúdo e desabilita o botão automaticamente — sem precisar do {isPending && <Spinner/>} manual.

import { Button } from '@kit/ui/button';

<Button type="submit" loading={isPending}>
Salvar alterações
</Button>
PropTipoPadrãoDescrição
loadingbooleanfalseMostra o spinner e desabilita o botão. Combina com disabled via OR — o botão fica desabilitado se qualquer um dos dois for true.

Não passe um ícone próprio como filho leading do botão quando usar loading — o spinner já ocupa essa posição, e os dois juntos duplicariam o espaço.

FormFieldSkeleton​

Exportado junto dos outros primitivos de formulário em @kit/ui/form. Substitui um campo inteiro (label + controle) por um efeito de esqueleto (Skeleton) enquanto os dados de que ele depende ainda não chegaram — mesma altura de Input/Select (h-8) e mesmo espaçamento do FormItem (gap-y-2), para não haver salto de layout quando o campo real aparece.

import { FormField, FormFieldSkeleton } from '@kit/ui/form';

{isLoadingOptions ? (
<FormFieldSkeleton />
) : (
<FormField
name="estado"
render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
{/* ... */}
</FormItem>
)}
/>
)}
PropTipoPadrãoDescrição
classNamestring—Classe do wrapper externo (div).
labelClassNamestring—Classe da barra de skeleton que representa o label — use para ajustar a largura (w-24 por padrão).

Use FormFieldSkeleton só para o estado de carregando de verdade (a busca ainda está em andamento). Para o estado de erro (a busca falhou), o campo deve continuar visível — ver TriggerStatus abaixo. Um skeleton em cima de um erro passaria a mensagem errada ("ainda carregando"), quando na real a busca já terminou e falhou.

TriggerStatus​

Componente novo em @kit/ui/trigger-status. Mostra o estado de carregando ou erro dentro de um SelectTrigger/ComboboxTrigger, no lugar do SelectValue, de um jeito só — em vez das variações que existiam antes (spinner dentro do trigger, texto no placeholder, ou nada).

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
import { TriggerStatus } from '@kit/ui/trigger-status';

<Select value={field.value} onValueChange={field.onChange} disabled={isOptionsError}>
<SelectTrigger>
{isOptionsError ? (
<TriggerStatus error />
) : (
<SelectValue placeholder="Selecione o estado" />
)}
</SelectTrigger>
<SelectContent>
{/* ... */}
</SelectContent>
</Select>
PropTipoPadrãoDescrição
loadingboolean—Mostra spinner + loadingLabel, em cinza (text-muted-foreground).
errorboolean—Mostra ícone de alerta + errorLabel, em vermelho (text-destructive). Tem prioridade sobre loading se os dois vierem true.
loadingLabelstring"Carregando..."Texto ao lado do spinner.
errorLabelstring"Erro ao carregar"Texto ao lado do ícone de erro.
classNamestring—Classe do span externo.

Para um Combobox de busca (ComboboxInput, sem SelectTrigger), não há como encaixar o TriggerStatus como filho — nesse caso, sinalize o erro passando aria-invalid pro ComboboxInput (ativa a mesma borda/ring vermelhos que o TriggerStatus usa) e, se fizer sentido, troque o placeholder para o texto de erro. Ver grupo-empresa-combobox.tsx como referência.

Exemplo completo​

Um campo assíncrono típico combina os três: FormFieldSkeleton durante o carregamento, TriggerStatus no erro, e Button com loading no submit. Trecho real de empresa-form-dialog.tsx:

{isLoadingOptions ? (
<FormFieldSkeleton />
) : (
<FormField
name="estado"
render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isOptionsError}
>
<FormControl>
<SelectTrigger>
{isOptionsError ? (
<TriggerStatus error />
) : (
<SelectValue placeholder="Selecione o estado" />
)}
</SelectTrigger>
</FormControl>
<SelectContent>
{estadoOptions.map((uf) => (
<SelectItem key={uf} value={uf}>{uf}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}

<Button type="submit" loading={isPending}>
Salvar alterações
</Button>

Quando não usar​

Esses componentes representam estado assíncrono de verdade — uma busca que ainda não voltou. Se um "Consultar"/filtro só aplica um valor já carregado, em memória, de forma síncrona (useState puro, sem useQuery/useAction/navegação), não há nada para o loading representar — adicionar um spinner nesse caso seria cosmético e enganoso, sem refletir nenhuma espera real.

Localização dos arquivos​

packages/ui/src/shadcn/button.tsx
packages/ui/src/shadcn/form.tsx
packages/ui/src/shadcn/trigger-status.tsx

Exportados em packages/ui/package.json:

"./button": "./src/shadcn/button.tsx",
"./form": "./src/shadcn/form.tsx",
"./trigger-status": "./src/shadcn/trigger-status.tsx"