Readiness: Implemented
Start
Build a reading list
Build an article list with topic filters, saved articles and a suggestion form. Then test it and create a static build.
Before you start
You need uf installed and a JavaScript host such as Node.js. Your first project explains the generated files.
Create the project
uf new reading-list
cd reading-list
uf install
uf add @uniflowed/state@alpha @uniflowed/ui@alpha @uniflowed/form@alpha
uf add --dev @uniflowed/test @uniflowed/react-testing
uf new uses the React template by default. The extra packages provide the
state, UI, form and component-testing APIs used below. UF packages are currently
published under the alpha tag; see package status.
Replace uf.config.js with this configuration so the task names match the
commands in this tutorial:
// @flow
import { defineConfig } from "@uniflowed/config";
export default defineConfig({
tasks: {
"verify:format": { command: "uf fmt --check" },
"verify:source": { command: "uf check" },
verify : { command: "uf test", dependsOn: ["verify:format", "verify:source"] },
bundle : { command: "uf build", dependsOn: ["verify"] },
},
});
uf run verify checks formatting and source in parallel, then runs tests.
uf run bundle builds after verification succeeds. uf check includes linting
and type checking. Tasks explains how dependsOn works.
Delete app/Counter.js, app/useCounter.js and app/$page.test.js.
The following sections replace the example pages and add the application's files.
Define the articles
Create app/articles.js:
// @flow
/// Supported article topics. `topicLabel` handles each one.
export type Topic = "flow" | "react" | "rust";
/// An article with a unique URL slug.
export type Article = {
readonly slug: string,
readonly title: string,
readonly author: string,
readonly minutes: number,
readonly topic: Topic,
};
const ARTICLES: $ReadOnlyArray<Article> = [
{
slug: "component-syntax",
title: "Component syntax, and what it removes",
author: "Ada",
minutes: 6,
topic: "flow",
},
{
slug: "the-compiler-is-not-magic",
title: "The React Compiler is not magic",
author: "Grace",
minutes: 11,
topic: "react",
},
{
slug: "one-parse",
title: "One parse, five tools",
author: "Alan",
minutes: 4,
topic: "rust",
},
];
export function listArticles(): $ReadOnlyArray<Article> {
return ARTICLES;
}
export function findArticle(slug: string): Article | null {
return ARTICLES.find((article) => article.slug === slug) ?? null;
}
export function topicLabel(topic: Topic): string {
return match (topic) {
"flow" => "Flow",
"react" => "React",
"rust" => "Rust",
};
}
match checks every member of the Topic union. If you add a topic, uf check
reports any missing case. Use readonly for read-only properties; the older
+ variance syntax is deprecated. See Flow syntax.
Create the list page
Replace app/$page.js. Its loader runs before the page renders:
// @flow
import { Link } from "@uniflowed/router";
import { AddArticle } from "./AddArticle.js";
import { type Article, listArticles } from "./articles.js";
import { TopicTabs } from "./TopicTabs.js";
export async function loader(): Promise<{ readonly articles: $ReadOnlyArray<Article> }> {
return { articles: listArticles() };
}
export component Page(data: { readonly articles: $ReadOnlyArray<Article> }) {
return (
<>
<h1>Reading list</h1>
<TopicTabs articles={data.articles} />
<h2>Everything</h2>
<ul>
{data.articles.map((article) => (
<li key={article.slug}>
<Link to={`/articles/${article.slug}`}>{article.title}</Link>
</li>
))}
</ul>
<AddArticle />
</>
);
}
Pages receive params, searchParams and data. This page only needs data.
Declare its type explicitly: uf does not infer page props from the loader's
return type. Use the named Page export to pass the component lint rule.
Add an article route
Create app/articles/[slug]/$page.js. The [slug] directory captures a URL
segment, such as /articles/one-parse:
// @flow
import { notFound } from "@uniflowed/router";
import { type Article, findArticle, listArticles, topicLabel } from "../../articles.js";
export function generateStaticParams(): $ReadOnlyArray<{ readonly slug: string }> {
return listArticles().map((article) => ({ slug: article.slug }));
}
export async function loader({
params,
}: {
readonly params: { readonly slug: string },
}): Promise<{ readonly article: Article }> {
const article = findArticle(params.slug);
if (article == null) {
throw notFound();
}
return { article };
}
export component Page(data: { readonly article: Article }) {
return (
<article>
<h1>{data.article.title}</h1>
<p>
{data.article.author} · {topicLabel(data.article.topic)} · {data.article.minutes} min
</p>
</article>
);
}
generateStaticParams lists the article pages to prerender. Without it, this
dynamic route needs a server and will return 404 on a static host.
Use throw notFound() here so Flow knows the missing-article branch ends.
Calling notFound() alone does not narrow article in the following code.
Add the layout
Replace app/$layout.js. It renders the document and receives the current page
as children:
// @flow
import * as React from "@uniflowed/react";
import { Link } from "@uniflowed/router";
import { SavedCount } from "./SavedCount.js";
export component Layout(children: React.Node) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reading list</title>
</head>
<body>
<header>
<Link to="/">Reading list</Link>
<SavedCount />
</header>
<main>{children}</main>
</body>
</html>
);
}
React.Node describes renderable children. The layout stays mounted during
navigation between its pages, so the header remains in place.
Share state between components
Create app/state.js. Atoms hold values, selectors compute derived values, and
actions update them:
"use client";
// @flow
import { action, atom, selector } from "@uniflowed/state";
/// Saved article slugs, shared by the list and header.
export const savedSlugs = atom<$ReadOnlyArray<string>>([]);
/// The header updates when the saved count changes.
export const savedCount = selector<number>((get) => get(savedSlugs).length);
/// Toggle a saved article without subscribing to the list.
export const toggleSaved = action<string>((get, set, slug) => {
const saved = get(savedSlugs);
set(savedSlugs, saved.includes(slug) ? saved.filter((each) => each !== slug) : [...saved, slug]);
});
/// A suggestion needs its own ID because titles may repeat.
export type Suggestion = {
readonly id: number,
readonly title: string,
};
/// Suggestions are stored in memory and reset on reload.
export const suggestions = atom<$ReadOnlyArray<Suggestion>>([]);
let nextSuggestionId = 0;
export const suggest = action<string>((get, set, title) => {
nextSuggestionId += 1;
set(suggestions, [...get(suggestions), { id: nextSuggestionId, title }]);
});
Create app/SavedCount.js to display the derived count:
"use client";
// @flow
import { useAtomValue } from "@uniflowed/state";
import { savedCount } from "./state.js";
export component SavedCount() {
const count = useAtomValue(savedCount);
return <span aria-live="polite">{count} saved</span>;
}
"use client" marks these modules for the browser. If you later use server-side
rendering, give each request a separate store with Provider to avoid sharing
state between users. This tutorial prerenders the pages.
The state guide covers stores and async values.
Filter articles with tabs
Create app/TopicTabs.js:
"use client";
// @flow
import { useAtomValue, useSetAtom } from "@uniflowed/state";
import { Tabs } from "@uniflowed/ui";
import { type Article, type Topic, topicLabel } from "./articles.js";
import { savedSlugs, toggleSaved } from "./state.js";
const TOPICS: $ReadOnlyArray<Topic> = ["flow", "react", "rust"];
component SaveButton(slug: string) {
const saved = useAtomValue(savedSlugs);
const toggle = useSetAtom(toggleSaved);
const isSaved = saved.includes(slug);
return (
<button type="button" aria-pressed={isSaved} onClick={() => toggle(slug)}>
{isSaved ? "Saved" : "Save"}
</button>
);
}
export component TopicTabs(articles: $ReadOnlyArray<Article>) {
return (
<Tabs.Root defaultValue="flow">
<Tabs.List aria-label="Topics">
{TOPICS.map((topic) => (
<Tabs.Tab key={topic} value={topic}>
{topicLabel(topic)}
</Tabs.Tab>
))}
</Tabs.List>
{TOPICS.map((topic) => (
<Tabs.Panel key={topic} value={topic}>
<ul>
{articles
.filter((article) => article.topic === topic)
.map((article) => (
<li key={article.slug}>
{article.title} <SaveButton slug={article.slug} />
</li>
))}
</ul>
</Tabs.Panel>
))}
</Tabs.Root>
);
}
Tabs connects the tab, tab list and panel ARIA attributes. It supports arrow
keys, Home and End, with one tab in the page's tab order.
The components have no default styles. Use className with a stylesheet,
CSS Modules or uf's styling tools.
See UI components for more controls.
Add a suggestion form
Create app/AddArticle.js:
"use client";
// @flow
import { useForm } from "@uniflowed/form";
import { useAtomValue, useSetAtom } from "@uniflowed/state";
import { suggestions, suggest } from "./state.js";
export component AddArticle() {
const { register, handleSubmit, errorProps, formState, reset } = useForm({
defaultValues: { title: "", minutes: "" },
});
const add = useSetAtom(suggest);
const added = useAtomValue(suggestions);
return (
<section>
<h2>Suggest an article</h2>
<form
onSubmit={handleSubmit((values) => {
add(values.title);
reset();
})}
>
<label htmlFor="title">Title</label>
<input id="title" {...register("title", { required: "A title is required" })} />
{formState.errors.title != null && (
<p {...errorProps("title")}>{formState.errors.title.message}</p>
)}
<label htmlFor="minutes">Minutes</label>
<input
id="minutes"
{...register("minutes", {
required: "How long is it?",
valueAsNumber: true,
min: { value: 1, message: "At least a minute" },
})}
/>
{formState.errors.minutes != null && (
<p {...errorProps("minutes")}>{formState.errors.minutes.message}</p>
)}
<button type="submit" disabled={formState.isSubmitting}>
Add
</button>
</form>
<ul>
{added.map((suggestion) => (
<li key={suggestion.id}>{suggestion.title}</li>
))}
</ul>
</section>
);
}
register provides the input's name and ref. When validation fails, it also
sets aria-invalid and connects the input to the message from errorProps.
Each suggestion has a unique ID, so repeated titles still have distinct React keys. Suggestions are stored in memory and disappear on reload.
Errors use flat keys, such as errors["profile.city"]. For typed nested values,
pass path segments: getValues("profile", "city").
The form guide covers validation and render behavior.
Test the application
Create app/articles.test.js for the data functions:
// @flow
import { describe, expect, it } from "@uniflowed/test";
import { findArticle, listArticles, topicLabel } from "./articles.js";
describe("the reading list", () => {
it("finds an article by its slug", () => {
expect(findArticle("one-parse")?.author).toBe("Alan");
});
it("answers null for a slug nothing matches", () => {
expect(findArticle("nope")).toBe(null);
});
it("gives every topic a label", () => {
for (const article of listArticles()) {
expect(topicLabel(article.topic)).not.toBe("");
}
});
});
Create app/TopicTabs.test.js for tab and save-button interactions:
// @flow
import { describe, expect, it } from "@uniflowed/test";
import { render, screen, userEvent } from "@uniflowed/react-testing";
import { listArticles } from "./articles.js";
import { TopicTabs } from "./TopicTabs.js";
describe("TopicTabs", () => {
it("shows one topic at a time", () => {
render(<TopicTabs articles={listArticles()} />);
expect(screen.getByRole("tabpanel").textContent).toContain("Component syntax");
expect(screen.queryByText(/One parse/)).toBe(null);
});
it("moves between topics with the arrow keys", async () => {
render(<TopicTabs articles={listArticles()} />);
await userEvent.click(screen.getByRole("tab", { name: "Flow" }));
await userEvent.keyboard("{ArrowRight}");
expect(screen.getByRole("tabpanel").textContent).toContain("React Compiler");
});
});
@uniflowed/test and @uniflowed/react-testing provides the test runner and DOM helpers. It creates the
test DOM on the first render; no setup file is needed.
Create app/AddArticle.test.js to check validation and accessible errors:
// @flow
import { describe, expect, it } from "@uniflowed/test";
import { fireEvent, render, screen, userEvent } from "@uniflowed/react-testing";
import { AddArticle } from "./AddArticle.js";
describe("AddArticle", () => {
it("reports a missing title, and wires the message to the input", async () => {
render(<AddArticle />);
// The submit event, at the control a person would press. A query is what
// hands `fireEvent` an element rather than an `Element | null`, which is
// the difference between a test the checker reads and one it cannot.
fireEvent.submit(screen.getByRole("button", { name: "Add" }));
const message = await screen.findByText("A title is required");
expect(message.getAttribute("role")).toBe("alert");
const title = screen.getByLabelText("Title");
expect(title.getAttribute("aria-invalid")).toBe("true");
expect(title.getAttribute("aria-describedby")).toBe(message.getAttribute("id"));
});
it("adds the article once both fields are answered", async () => {
render(<AddArticle />);
await userEvent.type(screen.getByLabelText("Title"), "Reading the Flow parser");
await userEvent.type(screen.getByLabelText("Minutes"), "9");
await userEvent.click(screen.getByRole("button", { name: "Add" }));
expect(await screen.findByText("Reading the Flow parser")).toBeInTheDocument();
});
});
Run the tests:
uf test
Check, format and build
uf fmt
uf run bundle
uf fmt formats your changes. The bundle task then checks formatting, runs
lint and type checks, runs tests, and builds the application.
The build writes the home page and each article listed by
generateStaticParams to dist/. To inspect bundle sizes, run
uf build --size-report.
Build reports are stored in .uf/build/meta/, outside the public output.
Set site.url to generate sitemap.xml and robots.txt.
Use uf dev while editing, uf preview to inspect the build, and
Deploy a web build to choose a deployment target.
Next steps
That is the end of Start. The toolchain is next: the commands you run every day — the dev server and the build, the tests, the type checker, the formatter and the linter. Build an app, after it, has one guide per feature: routing, data, actions, forms and UI.
Edit this pagedocs/app/guide/tutorial/$page.mdx