Skip to content
Chapter 17Lesson 4

Actions, navigations, and item sequences

A guide to choosing the right semantic HTML element, button, link, or list, for every interactive control in your React components.

The last lesson handed you the page’s rooms: <header>, <nav>, <main>, <footer>, and a heading outline inside them. Now you wire up what the user clicks inside those rooms. Take one ordinary screen in the Acme invoicing app. It has a Save button that writes the open invoice, a Go to dashboard action that takes you to /dashboard, and a short list of plan features in the sidebar. There are three things on screen, and the easy instinct reaches for whatever tag is closest to hand: a <div onClick> for Save, a <button> for the navigation because that’s the tag you styled last, and a stack of <div>s for the features. The result renders, and it looks right.

Here is that screen written two ways. Read the first sentence of each tab before the code.

<div onClick={save}>Save</div>
<button onClick={() => router.push('/dashboard')}>Dashboard</button>
<div>Unlimited invoices</div>
<div>Priority support</div>

Every control here is a <div> or a <button> chosen by habit, not by what it does. It looks identical to the version on the right, and behaves nothing like it.

Open both in a browser and you cannot tell them apart, and that is the trap. The two versions render the same pixels, but they behave completely differently the moment anyone uses a keyboard, a screen reader, or a right-click. That difference is invisible to the person who wrote the code. This lesson teaches the one decision that separates the two: behavior picks the element, the class picks the look. A control that performs an action is a <button> even when it’s styled to look like a link, and a control that goes to a URL is a link even when it’s styled to look like a button. The look is a separate knob, one you’ll reach for in the next chapter with Tailwind, and it never changes which element is correct. This is the same split the last lesson left you with, where the element carried the page outline and the class carried the look. Here the element carries the page’s interactivity instead.

By the end you’ll pick correctly by reflex across three families: the things that do something, the things that go somewhere, and the things that form a sequence. You’ll also know exactly how much free behavior you’d throw away by guessing wrong.

A <button> is a control that performs an action right here on the current page: submit a form, open a modal, delete a row, toggle a sort order. Its defining trait is that it acts and you stay put. A button never navigates; that job belongs to links, the next section. When the thing you’re building does something on click and doesn’t take the user to a URL, it’s a button.

So why not a <div onClick>, which fires the same handler? The difference is everything the browser does for a real <button> that you’d otherwise have to write by hand:

  • Keyboard focus. A <button> is in the Tab order automatically. A <div> is not.
  • Keyboard activation. Enter and Space fire a <button>’s click, the convention every keyboard user expects. A <div> responds to neither.
  • A focus ring. The browser draws a visible focus indicator when the button is tabbed to, so a keyboard user can see where they are.
  • The role. A screen reader announces it as “button,” so a non-sighted user knows it’s actionable and how to activate it. A <div> announces nothing.
  • Disabled handling. Setting disabled removes the button from the activation path and dims it, in one attribute.

Every one of those is behavior you don’t have to write. Reach for <div onClick> instead and you’ve taken on rebuilding all five by hand, which is the cost the back half of this lesson keeps returning to.

This is one of the most common bugs in this area, and it is worth slowing down for. A <button> placed inside a <form> defaults to type="submit", so a click submits the surrounding form whether or not that’s what you intended. The attribute takes one of three lowercase values. Lowercase matters here: type="Submit" does nothing and reports no error, the same casing trap you saw with JSX attributes.

  • type="submit" submits the form. This is the default inside a form.
  • type="button" is inert by default. It runs only its own onClick and submits nothing.
  • type="reset" clears the form back to its initial values. It’s rare, surprising to users, and an anti-pattern in practice, so know it exists and move on.

An experienced developer carries one reflex here: every <button> declares its type explicitly. Not because the default is wrong, but because it’s invisible and the failure it causes is expensive. Picture a form with two buttons side by side, Save and Cancel, where the Cancel button has no type. The user fills in half an invoice, decides to bail, and clicks Cancel. Because Cancel defaulted to submit, the form submits the half-finished invoice instead of abandoning it. The user lost their work, and no one wrote a single line of buggy logic to cause it. One missing attribute did.

In the next lesson you’ll wire this form to a real Server Action. Here it’s just the shell, so you can watch the one-attribute fix.

function InvoiceActions() {
return (
<form action={saveInvoice}>
<button type="submit">Save</button>
<button type="button" onClick={discard}>Cancel</button>
</form>
);
}

The form wrapper. action is the Server Action that runs on submit. The next lesson wires it up, so ignore it for now and watch the buttons.

function InvoiceActions() {
return (
<form action={saveInvoice}>
<button type="submit">Save</button>
<button type="button" onClick={discard}>Cancel</button>
</form>
);
}

Save is the primary action, so type="submit" is exactly right. Clicking it, or pressing Enter in a field, submits the form.

function InvoiceActions() {
return (
<form action={saveInvoice}>
<button type="submit">Save</button>
<button type="button" onClick={discard}>Cancel</button>
</form>
);
}

Now imagine this Cancel button with no type at all. Inside a form it would default to type="submit", so clicking Cancel would submit the half-finished invoice. That is the bug, and nothing warns you about it.

function InvoiceActions() {
return (
<form action={saveInvoice}>
<button type="submit">Save</button>
<button type="button" onClick={discard}>Cancel</button>
</form>
);
}

The fix is one attribute. type="button" makes Cancel inert to the form: it runs only its onClick and submits nothing.

1 / 1

disabled is a boolean attribute: write disabled to turn it on, omit it to leave it off, the same on-or-off prop shape you’ve already seen in JSX. A disabled button can’t be activated, the browser dims it, and it drops out of the activation path entirely.

The thing to watch is the effect on the user, not the syntax. A disabled button is often also skipped in the Tab order, and it offers no explanation for why it won’t work. A customer ends up staring at a greyed-out Send invoice button with no idea that it’s waiting on a required field three rows up. So the rule is to never hide a critical action behind a bare disabled without surfacing the reason somewhere the user can find it. That can be an inline message, a tooltip, or a hint wired to the button with aria-describedby, an attribute you’ll meet properly in the data-and-aria lesson. Disabling a control is easy; the real mistake is leaving the user stranded with no way to know why.

Plenty of buttons in a real app have no text: a trash can to delete a row, a copy glyph, an X to dismiss. They look obvious to a sighted user. To a screen reader, a <button> whose only child is an icon has no accessible name , so the user hears “button” and nothing more, with no way to know what it does. The fix is one attribute. aria-label gives the button a name that assistive tech reads aloud, without putting any visible text on screen.

<button type="button" aria-label="Delete invoice" onClick={remove}>
<TrashIcon />
</button>

One thing to keep in mind for later: aria-label replaces the accessible name, so putting it on a button that already has visible text overrides that text. The screen reader reads the label and ignores the words on screen. Only reach for it when there’s no visible text to begin with. That’s the full extent of ARIA you need today; the data-and-aria lesson goes deep on the rest.

A link is the mirror image of a button. Where a button acts and keeps you in place, a link takes the user to a URL. That URL might be another page in your app, an external site, a downloadable file, or a spot further down the current page. If the job is to go somewhere, it’s a link.

The single most important thing about an <a> is its href. An <a> with an href is a real link: it’s focusable, Enter activates it, right-click offers “Open in new tab” and “Copy link,” Cmd/Ctrl-click and middle-click open it in a background tab, the destination shows in the status bar when you hover, and a search crawler can follow it. An <a> without an href is inert: it isn’t focusable, can’t be activated, and has no role. It looks like a link but does nothing.

That cuts both ways. If you find yourself writing an <a> with an onClick and no href, stop: a control that acts on click is a <button>, not a stripped-down link. The presence of href is the tell. It means the control goes somewhere, and no destination means it isn’t a link.

By default a link replaces the current page. Add target="_blank" and it opens in a new tab instead, which is the right call for an external site you don’t want to pull the user away to. But target="_blank" carries a security and privacy obligation, so the reflex is to pair it with rel="noopener noreferrer":

  • noopener cuts the new page’s handle back to the page that opened it. Without it, the opened site can reach back through window.opener and silently redirect your original tab to a phishing clone, an attack called tabnabbing .
  • noreferrer strips the Referer header, so the destination isn’t told which of your pages sent the visitor.

Modern browsers now imply noopener for target="_blank" on their own, so the worst case is mostly closed by default. Writing the rel explicitly is still the reflex, because defaults shift, older embedded webviews lag behind, and a senior reviewer would rather read the intent than audit which environments are safe. State what you mean rather than leaning on the browser to guess it.

Two more rel values you’ll see. On user-generated links such as a comment or a profile bio, nofollow tells crawlers not to lend your site’s ranking to whatever a stranger pasted. The other, external, is mostly a styling hook. Neither one matters the way the security pair does.

Two smaller link jobs round this out. The first is downloads: <a download href="/report.pdf"> turns a link into a file download instead of a navigation, and an optional value (download="invoice.pdf") renames the saved file. The second is in-page jumps. An href that starts with # points at an element on the same page by its id, so <a href="#pricing"> scrolls to whatever element has id="pricing" and updates the URL hash, with no new page load. That’s how a table of contents and the “skip to main content” link from the last lesson work. The target just needs a unique id, and you can soften the jump into a smooth scroll with one CSS line (scroll-behavior: smooth, which Tailwind exposes as scroll-smooth).

<a href="/pricing">Pricing</a>
<a href="https://stripe.com" target="_blank" rel="noopener noreferrer">Stripe</a>
<a href="#faq">Jump to FAQ</a>

For links inside your own app, Next.js gives you a better anchor. <Link> is not a separate thing to learn; it’s an <a> with one extra power. <Link href="/dashboard"> renders to a plain <a href="/dashboard"> in the HTML the browser receives, so everything you just learned about real anchors still holds: the crawler follows it, Cmd-click opens a new tab, “Copy link” works, and if JavaScript fails to load the link still navigates the old-fashioned way.

What it adds is soft navigation : clicking it changes the route without a full-document reload. There’s no white flash and no re-downloading the whole page, just the parts that changed. The decision rule is simple:

Internal routes use <Link>. External links use a plain <a target="_blank" rel="noopener noreferrer">.

The trap here is a navigation dressed up as a button. You’ll see <button onClick={() => router.push('/dashboard')}> in real codebases, a button whose entire job is to go to a URL. It works on click, but it isn’t a real link, so it loses copy-link, middle-click, and crawlability, and an experienced reviewer flags it on sight. If the control goes to a URL, it’s a <Link>, no matter how button-like it looks.

<Link href="/dashboard">Dashboard</Link>

An internal route. Renders to a real <a href> and adds soft navigation, with no full reload.

There’s real depth to <Link>, such as how it prefetches routes before you click and how it restores scroll position, but that belongs with the App Router later in the course. For today the rule is short: internal links use <Link>, external links use <a>, and a button is never the tool for navigation.

Section titled “Choosing the element: button, link, or div”

You now have both halves, so here’s the decision they were building toward, in one sentence: match the element to the behavior, not the look. Everything in this lesson comes back to that, along with two consequences worth keeping handy:

  • A <button> styled to look like a link still acts. It’s a button.
  • An <a> styled to look like a button still navigates. It’s a link.

The styling comes later and can change at any time. The element encodes what the control does, and that’s the part that has to be right.

The clearest way to see why the semantic element wins is to try to fake it. Suppose you insist on a <div> and want it to behave like a button. Here’s what that costs:

  • role="button" so assistive tech announces it correctly.
  • tabIndex={0} so it’s reachable by Tab.
  • An onKeyDown handler that fires on Enter and Space, and calls preventDefault on Space, or the page scrolls instead of activating.
  • Focus-ring CSS, because you’ve lost the browser’s default one.
  • aria-pressed if it’s a toggle.
  • cursor-pointer, because a <div> doesn’t get the pointer cursor.

That is the exact list a real <button> hands you for free, now rebuilt by hand. It’s more code, more places for bugs to hide, and you’ll very likely forget the Space-key preventDefault or the focus ring. So don’t do this. Reach for <button> and restyle it instead. The same logic rules out <div role="link">: if it navigates, it’s an <a>.

Walk the decision the way an experienced developer runs it in their head, one question at a time. The trap branch is there on purpose, so pick it once and see what it costs you.

Pick the element

To make the size of that “free” pile concrete, here’s the single <button> element and everything it hands you the moment you write it, with no props and no handlers, just the tag.

<button>
In the Tab order
Enter / Space activate
A visible focus ring
Announced as "button"
disabled drops it from the path

Write the bare <button> tag and the browser hands you all five behaviors. Choose a <div> instead and every one of them is now code you own.

Now fix some real mistakes. The screen below has three planted bugs: a delete control built as a <div>, a settings link faked with a navigating button, and a Cancel button inside a form with no type. Repair each one so it uses the right element and attributes.

Three controls are built with the wrong element. Fix each one: Delete should be a real <button> (not a <div>), Settings should navigate to /settings as a link, and the Cancel button inside the form must not submit it. This sandbox has no router, and <Link> renders to exactly the <a href> you'd write — so use a plain <a href="/settings"> for the navigation.

Preview
    Reveal the fixed version
    export function App() {
    return (
    <div className="flex flex-col gap-3">
    <button type="button" onClick={() => alert('deleted')}>Delete</button>
    <a href="/settings">Settings</a>
    <form>
    <button type="submit">Save</button>
    <button type="button">Cancel</button>
    </form>
    </div>
    );
    }

    The Delete control acts, so it’s a <button>, with an explicit type="button" since it isn’t inside a form-submit path. Settings goes to a URL, so it’s an <a href="/settings">, a real anchor and exactly what <Link> would render. Both form buttons declare their type: submit for Save, and button for Cancel so an untyped Cancel can’t silently submit the half-finished invoice.

    The third family is the lightest, because you already met it: <ul> for an unordered sequence, <ol> for an ordered one, and <li> for each item. The one structural rule is that <li> is the only thing allowed directly inside a <ul> or <ol>; nothing else belongs there as a direct child. Any sequence of related, parallel items is a list: navigation links, a feature grid, a comment thread, audit-log entries.

    You render them with the .map pattern from the JSX lesson, and the key rule carries over unchanged: one key per <li>, tied to the data’s identity, never the array index.

    <ul>
    {features.map((feature) => (
    <li key={feature.id}>{feature.name}</li>
    ))}
    </ul>

    Lists can nest for hierarchy, with a <ul> inside an <li> for a file tree or a threaded comment, and <ol> takes attributes like start, reversed, and type when you need to control the numbering, though you’ll usually leave that to the content. It’s enough to recognize these; you won’t reach for them often.

    Not everything that sits in a row is a list, and reaching for <ul> too often is as much a problem as not reaching for it enough. The test an experienced developer asks is short: would a screen-reader user usefully hear “list, three items” here? If yes, it’s a list. Navigation links, feature cards, and comments under a post are related and parallel, so they’re a list, and the “N items” announcement genuinely helps. A logo next to a sign-in button in the header, or a hero headline beside its call-to-action button, are unrelated things that merely happen to sit near each other, so they’re not a list. They’re just elements in a container.

    This is the inverse of the button trap. A nav built as a bare row of <a>s renders fine, but it silently drops the “list of N items” announcement that tells a screen-reader user how many destinations there are.

    This pattern pulls the chapter’s threads together. The last lesson named the primary navigation as a list but didn’t build it. Here it is, composed from four things you already know. The canonical SaaS nav is a <nav> landmark wrapping a <ul> of <li>, each holding a <Link>.

    const links = [
    { href: '/dashboard', label: 'Dashboard' },
    { href: '/invoices', label: 'Invoices' },
    { href: '/customers', label: 'Customers' },
    ];
    function PrimaryNav() {
    return (
    <nav aria-label="Primary">
    <ul className="flex gap-4 list-none">
    {links.map((link) => (
    <li key={link.href}>
    <Link href={link.href}>{link.label}</Link>
    </li>
    ))}
    </ul>
    </nav>
    );
    }

    The <nav> landmark from the last lesson, named with aria-label so a screen reader can tell this nav from any other on the page.

    const links = [
    { href: '/dashboard', label: 'Dashboard' },
    { href: '/invoices', label: 'Invoices' },
    { href: '/customers', label: 'Customers' },
    ];
    function PrimaryNav() {
    return (
    <nav aria-label="Primary">
    <ul className="flex gap-4 list-none">
    {links.map((link) => (
    <li key={link.href}>
    <Link href={link.href}>{link.label}</Link>
    </li>
    ))}
    </ul>
    </nav>
    );
    }

    A real semantic list, so assistive tech announces “list, 3 items.” flex gap-4 lays it out horizontally, and list-none drops the bullets. The semantics survive the styling: it’s still a list, just without visible markers.

    const links = [
    { href: '/dashboard', label: 'Dashboard' },
    { href: '/invoices', label: 'Invoices' },
    { href: '/customers', label: 'Customers' },
    ];
    function PrimaryNav() {
    return (
    <nav aria-label="Primary">
    <ul className="flex gap-4 list-none">
    {links.map((link) => (
    <li key={link.href}>
    <Link href={link.href}>{link.label}</Link>
    </li>
    ))}
    </ul>
    </nav>
    );
    }

    .map over the data, one <li> per link, each keyed by its href. That’s the stable, data-tied key from the JSX lesson, used here instead of the array index.

    const links = [
    { href: '/dashboard', label: 'Dashboard' },
    { href: '/invoices', label: 'Invoices' },
    { href: '/customers', label: 'Customers' },
    ];
    function PrimaryNav() {
    return (
    <nav aria-label="Primary">
    <ul className="flex gap-4 list-none">
    {links.map((link) => (
    <li key={link.href}>
    <Link href={link.href}>{link.label}</Link>
    </li>
    ))}
    </ul>
    </nav>
    );
    }

    An internal <Link> inside each <li>: soft navigation, and still a real <a href> underneath.

    1 / 1

    What makes this worth studying is how it fits together. The <nav> landmark, the list semantics, the data-tied keys, and <Link> navigation are four ideas from across this chapter, composed into the one piece of UI nearly every SaaS app ships, with the bullets gone but the meaning intact.

    Now sort the three families apart. Drop each control or piece of content into the element family it should use.

    Sort each control or piece of content into the element family it should use. Drag each item into the bucket it belongs to, then press Check.

    <button> Performs an action in place
    <a> / <Link> Goes to a URL
    <ul> / <ol> A sequence of related items
    Submit the sign-up form
    Open a confirmation modal
    Delete this invoice row
    Go to the billing page
    Open the docs in a new tab
    Jump to the FAQ section
    The app’s primary nav items
    Comments under a post
    Plan features in the pricing card

    One last thread to close, and it hands off to the next lesson on forms. You’ve already seen that a <button> inside a <form> defaults to type="submit". Two facts together make the explicit-type reflex non-negotiable inside a form: that default, plus the fact that pressing Enter in almost any text input also submits the form. So a form full of buttons (a Save, a Cancel, a “toggle advanced options”) submits on the first stray Enter or the first untyped button click, unless every non-submit button is marked type="button". That’s the whole reason the reflex exists.

    You’ll occasionally see a submit button carry its own formAction, formMethod, formEncType, formNoValidate, or formTarget. These override the parent form’s matching attributes for that one button, so two submit buttons can post to different places. It’s enough to recognize them; you’ll reach for them almost never. The real form machinery, the inputs, the labels, the name-to-data contract, and the Server Action that consumes it, is the next lesson.

    A quick check of the button thread before you go. Fill in the three blanks for a typical form’s controls.

    Fill in the type for each control. Remember: inside a form, an unmarked button defaults to submit. Pick the right option from each dropdown, then press Check.

    <form action={createInvoice}>
    <input type=___ name="email" />
    <button type=___>Save</button>
    <button type=___>Cancel</button>
    </form>

    Three families, one decision. A control that acts is a <button>, and you declare its type so a form can’t hijack the click. A control that goes somewhere is a link: a <Link> for internal routes, and a plain <a target="_blank" rel="noopener noreferrer"> for the outside world, never a <div> or a button in disguise. A sequence of related, parallel items is a <ul> or <ol> with one data-tied key per <li>. Behind every one of those choices sits the rule the whole lesson rests on: behavior picks the element, the class picks the look. Choose by behavior and the browser hands you keyboard support, focus, roles, and the right announcements for free. Choose by appearance and you take on rebuilding all of it by hand. The next lesson takes the form you kept glimpsing here and wires it into a real submission.