Ir para o conteúdo
Trilha
Capítulos

Receitas

Do Next.js para a Trilha

Os padrões de React que você escreve sem pensar — useEffect + fetch, useState do modal, toast, useSearchParams — e a linha de Go que substitui cada um.

ctx --pack · est. 20168 tokens est. · llms.txt da receita

Esta página é para quem já monta telas em Next.js e agora olha para um arquivo Go se perguntando onde foi parar o estado. É uma tabela de tradução, não um argumento: o app que está mudando de casa é um painel de artigos — lista com filtro e ordenação, uma fila que se atualiza sozinha, um modal de confirmação, um PDF — e cada seção mostra o React que você escreveria e a Trilha que ocupa o lugar dele.

A versão curta da página inteira: o estado que morava no browser passa a morar na URL ou no servidor, e o que sobra no browser é o pouco que o kit já traz pronto.

No app ReactAqui
useEffect + fetch + useState(loading)uma função de página que lê os dados e devolve o HTML
uma Server Action ("use server") e <form action={…}>um POST ao lado da página que grava e dá c.Redirect
useSearchParams e um estado por filtrotrilha.ListParams lida pelo c.Bind
setInterval + limpeza no useEffectui.Poll
toast() de um providerc.Flash
useState(open) + portal + tratador de Escapeui.Dialog
createObjectURL sobre um blobc.Inline
if (!user) router.replace("/login")Auth.Require() no middleware.go do ramo
estado da barra lateral no localStorageui.Shell
dangerouslySetInnerHTMLui.Markdown
uma biblioteca de gráfico no clientec.Island
next.config.jsapp/setup.go e Config

Antes da primeira linha de Go, trilha migrate next ../web grava a árvore de pastas do app/ e um MIGRATION.md dizendo, tela por tela, de qual arquivo veio, o que chamava e qual dos formatos abaixo ela provavelmente é. O esqueleto compila; o porte é o que sobra, e esta página é a referência dele. Veja o comando.

A tela que busca#

O painel em React: um efeito, três estados e um render que precisa dizer algo sensato em cada um deles.

export default function Dashboard() {
  const [rows, setRows] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  useEffect(() => {
    fetch("/api/articles")
      .then(r => r.json()).then(setRows)
      .catch(setError).finally(() => setLoading(false))
  }, [])
  if (loading) return <Spinner />
  if (error) return <Error err={error} />
  return <Table rows={rows} />
}

A mesma tela aqui é uma função. Ela roda no servidor, então ler o banco é uma chamada de função e a página chega pronta:

// Dashboard is app/dashboard/page.go. There is no useEffect, no loading flag
// and no error state: the function runs on the server, so it reads the
// database the way any Go function does and returns the page already filled.
// What React needed three renders for happens here before the first byte.
func Dashboard(c *trilha.Ctx) (h.Node, error) {
	var q DashQuery
	if err := c.Bind(&q); err != nil {
		return nil, err
	}
	rows, total, err := dashRows(c.Context(), q)
	if err != nil {
		return nil, err
	}
	return ui.DataTable(c, DashColumns, rows, ui.ListState{
		Params: q.ListParams,
		Total:  total,
		ID:     "articles",
		Search: "Search articles",
		Empty:  h.Text("No article matches this filter."),
		RowHref: func(i int) string {
			return "/dashboard/" + rows[i].Slug
		},
	}), nil
}

Não há estado de carregando porque não existe o instante em que a página existe sem os dados, e não há estado de erro porque o erro é um error devolvido — o framework transforma nele o status e a página que a pessoa vê.

Os filtros que eram estado#

Cada filtro do app React era um useState mais um useSearchParams para guardá-lo no endereço, mais um efeito para buscar de novo quando qualquer um dos dois mudasse. Aqui o endereço é o estado:

// DashQuery is the whole state the dashboard used to keep in React: the page,
// the ordering, the search box and the type filter. Embedding ListParams is
// what makes them live in the address instead of in memory — the visitor can
// bookmark the screen, reload it, or send it to somebody else.
type DashQuery struct {
	trilha.ListParams
	Status string `form:"status"`
}

O c.Bind preenche as duas metades a partir da query, aplica os limites de uma listagem (página no mínimo 1, tamanho de página com teto) e devolve a struct. As colunas dizem por quais delas dá para ordenar:

// DashColumns replaces the <thead> written by hand and the onClick that
// re-sorted the array in the browser. Sort: true turns the header into a real
// link — the ordering is a GET, so it works with the script blocked and it is
// still there after a reload.
var DashColumns = ui.Columns[Article]{
	{Key: "title", Label: "Title", Sort: true, Cell: func(a Article) h.Node {
		return h.Text(a.Title)
	}},
	{Key: "published", Label: "Published", Sort: true, Cell: func(a Article) h.Node {
		return h.Text(a.Published.Format("2006-01-02"))
	}},
}

E a consulta é onde a URL deixa de ser confiável:

// dashRows is the query behind the screen. The ordering comes from the URL,
// so it is checked before it reaches SQL: Restrict drops a column nobody
// declared, and what is left is one of two names this function knows.
func dashRows(ctx context.Context, q DashQuery) ([]Article, int, error) {
	q.Restrict("title", "published")
	order := `published_at DESC`
	if q.Sort == "title" {
		order = `title`
		if !q.Asc() {
			order = `title DESC`
		}
	}
	rows, err := DB.QueryContext(ctx,
		`SELECT id, slug, title, published_at FROM articles WHERE ($1 = '' OR title ILIKE '%' || $1 || '%') AND ($2 = '' OR status = $2) ORDER BY `+order+` LIMIT $3 OFFSET $4`,
		q.Q, q.Status, q.Limit(), q.Offset())
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	var out []Article
	for rows.Next() {
		var a Article
		if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Published); err != nil {
			return nil, 0, err
		}
		out = append(out, a)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	var total int
	err = DB.QueryRowContext(ctx, `SELECT count(*) FROM articles WHERE ($1 = '' OR status = $1)`, q.Status).Scan(&total)
	return out, total, err
}

O intervalo que vazava#

useEffect(() => {
  const id = setInterval(() => refetch(), 6000)
  return () => clearInterval(id)
}, [])

O fragmento pede a si mesmo, e quem decide quando parar é o servidor:

// Queue is the block that used to be a useEffect with a setInterval and a
// cleanup nobody remembered to write. ui.Poll asks this same route for the
// fragment every six seconds; when the work is done the handler stops the
// clock from the server side and the browser stops asking.
func Queue(c *trilha.Ctx) (h.Node, error) {
	left, err := pendingArticles(c.Context())
	if err != nil {
		return nil, err
	}
	if left == 0 {
		c.PollStop()
		return h.Div(h.ID("queue"), h.Text("Everything processed.")), nil
	}
	return h.Div(h.ID("queue"), ui.Poll("6s", "#queue"),
		h.Text(strconv.Itoa(left)+" articles in the queue"),
	), nil
}

c.PollStop() escreve um cabeçalho que o kit lê; c.PollEvery(d) muda o intervalo do mesmo lugar. O browser ainda pausa enquanto a aba está escondida — comportamento que você teria de escrever e de lembrar de escrever de novo na tela seguinte.

O toast#

toast.success("Artigo publicado") precisa de um provider na raiz, de um portal e de um estado que sobreviva à navegação. O flash é escrito antes do redirect e mostrado pela página que vem depois:

// Publish is the POST behind the button. The toast() of the React app is a
// flash here: the message is written before the redirect and shown by the
// page that comes next, so a refresh does not repeat the action and the
// message survives the navigation without any state to carry.
func Publish(c *trilha.Ctx) error {
	if _, err := DB.ExecContext(c.Context(), `UPDATE articles SET status = 'published' WHERE slug = $1`, c.Param("slug")); err != nil {
		return err
	}
	c.Flash(ui.FlashSuccess, "Article published")
	return c.Redirect("/dashboard")
}

O modal#

O modal em React é um useState(false), um portal, um efeito para o Escape e um foco preso por biblioteca. O <dialog> faz os quatro:

// ConfirmDelete is the modal that used to be a useState(false), a portal and
// an effect closing it on Escape. The dialog is in the HTML from the start,
// closed; the browser opens it, traps the focus and closes it on Escape by
// itself, because <dialog> already does all of that.
func ConfirmDelete(slug string) h.Node {
	return h.Div(
		ui.DialogTrigger("delete", h.Text("Delete")),
		ui.Dialog("delete", "Delete this article?",
			h.P(h.Text("This cannot be undone.")),
			h.Form(h.Method("post"), h.Action("/dashboard/"+slug+"/delete"),
				ui.Button(ui.Destructive(), h.Text("Delete")),
			),
		),
	)
}

O blob#

const res = await fetch(`/api/preview/${slug}`)
const url = URL.createObjectURL(await res.blob())
setSrc(url)                       // e revogue, se o componente viver até lá

Um endereço é mais simples que um blob, e sobrevive a um recarregamento:

// Preview answers the PDF the <iframe> shows. In the Next.js app this was a
// route handler returning a blob, a createObjectURL and a revoke that leaked
// when the component unmounted early: here the browser asks for a URL and
// gets a document.
//
// Nothing else is needed for the <iframe>: Inline is the answer that says it
// may be framed by a page of this origin. It is the framed document that
// refuses, never the page around it — a page adding frame-src to its own
// policy changes nothing while the file it frames still carries DENY.
func Preview(c *trilha.Ctx) error {
	f, err := os.Open("var/previews/" + c.Param("slug") + ".pdf")
	if err != nil {
		return trilha.ErrNotFound
	}
	defer f.Close()
	return c.Inline("preview.pdf", f, "application/pdf")
}

O redirect que ninguém escreveu duas vezes#

if (!user) router.replace("/login") no topo de cada página é regra mantida por repetição — e a décima primeira página esquece. Aqui a regra é a pasta:

// Middleware is the whole of app/dashboard/middleware.go, and it replaces the
// `if (!user) router.replace("/login")` that every page repeated. The check
// runs before the handler, so a route added tomorrow is covered by having been
// put in this folder — not by remembering the first line.
func Middleware(c *trilha.Ctx, next trilha.Next) error {
	return Panel.Require()(c, next)
}

Tudo que está sob app/dashboard/ está coberto por ter sido posto ali. O Panel que ele usa é montado uma vez, no app/setup.go:

// Panel is the session of the migrated app. It is set in app/setup.go; the
// pages never touch it.
var Panel *auth.Auth

A moldura#

Barra lateral, barra de cima, menu do usuário, o link ativo, o estado recolhido no localStorage: uma semana de trabalho no app React, e uma struct de opções aqui.

// Frame is app/dashboard/layout.go: the sidebar, the top bar and the user
// menu that the React app assembled from a component library, a state for the
// collapsed sidebar and a localStorage to remember it. Shell renders the
// frame; the current item comes from the path, so no page has to say which
// link is active.
func Frame(c *trilha.Ctx, children ...h.Node) h.Node {
	return ui.Shell(c, ui.ShellOpts{
		Brand:   h.Text("Editorial"),
		Current: c.Request().URL.Path,
		Nav: []ui.NavGroup{{Label: "Content", Items: []ui.NavItem{
			{Href: "/dashboard", Label: "Articles", Icon: "file"},
			{Href: "/dashboard/queue", Label: "Queue", Icon: "clock"},
		}}},
		User: ui.UserMenu{Name: "Editor", Items: []h.Node{
			ui.MenuLink("/logout", h.Text("Sign out")),
		}},
	}, children...)
}

O HTML que não foi você que escreveu#

dangerouslySetInnerHTML é a saída de emergência que termina em incidente. O ui.Markdown renderiza para uma árvore de nós, então uma tag na origem é texto na saída:

// Notes renders text written by a person or by a model. It is the answer to
// dangerouslySetInnerHTML: the source becomes a tree of nodes, a tag in the
// source comes out as text, and there is no string on the way that anybody
// could hand to h.Raw.
func Notes(src string) h.Node {
	return ui.Markdown(src, ui.MarkdownOpts{HeadingBase: 3})
}

O que continua no browser#

Nem tudo deve mudar de lado. Uma biblioteca de gráfico, um mapa, um editor de texto rico — código que roda no cliente de verdade — vira uma ilha: um módulo, as props em JSON e os filhos renderizados no servidor como alternativa.

// Sales is the one place the React answer was right: a chart library that
// runs in the browser. The island loads the module, hands it the data as
// JSON and mounts it on this element — and the children are what a visitor
// with the script blocked reads instead.
func Sales(c *trilha.Ctx, points []float64) h.Node {
	return c.Island("/chart.js", map[string]any{"points": points},
		h.Class("chart"),
		ui.Sparkline(points, ui.SparkOpts{}),
	)
}

Essa é a história inteira do lado do cliente: sem bundler, sem hidratar o resto da página, e a alternativa é o que lê quem está com o script bloqueado.

next.config.js#

// SetupPreviews is the last piece of the move. next.config.js had rewrites,
// headers and a public/ folder; here public/ is served by the framework and
// the rest is this: a mount for the generated files. The <iframe> needs no
// entry at all — that line used to be here, and it never did anything.
func SetupPreviews(cfg *trilha.Config) {
	cfg.Mounts = map[string]fs.FS{"/previews/": os.DirFS("var/previews")}
}

Achar o nome#

O reflexo "como é que se chama esse componente?" tem resposta que não precisa do browser:

trilha ui describe            # tudo, agrupado
trilha ui describe DataTable  # assinatura, para que serve, um exemplo

Ele lê um catálogo montado a partir dos próprios comentários do kit, então não tem como divergir do código que descreve — e o --json o deixa legível para o agente que escreve a tela com você.