Skip to main content
Back to Blog

2 min read

Logic in lib, islands thin

How separating pure logic from React islands made 190+ tools testable, portable, and cheap to build — without ever writing a component test.

architecture react testing astro

Every interactive tool on this site follows the same three-file pattern:

  1. src/lib/tool-name.ts — pure logic, zero side effects, fully unit-tested
  2. src/islands/ToolName.tsx — thin React shell for state and UI
  3. src/content/tools/.../tool-name.mdx — content and metadata

The island imports the lib. The lib knows nothing about React. This separation is the single most useful architectural decision in the project.

What “thin” means

A thin island does three things: holds state, calls lib functions, renders output. It doesn’t compute, parse, validate, or transform. All of that lives in the lib.

Take the cron parser. The lib (src/lib/cron.ts) exports parseCron(), summarizeCron(), and nextRuns() — pure functions that accept strings and return typed objects. The island (src/islands/CronParser.tsx) is a form with an input field, a useMemo call to summarizeCron(), and a list that renders the results.

The password generator follows the same shape. src/lib/password-generator.ts handles entropy calculation, charset pooling, and crypto.getRandomValues() integration. The island is a slider, some checkboxes, and a display.

In both cases, the island is under 130 lines. The interesting code is in the lib.

Why this matters for testing

The project has 6,900+ tests at 94% statement coverage — and zero component tests.

That’s not a gap. It’s a design choice. When all logic lives in pure functions, you test the logic with simple input/output assertions. No render mounts, no fireEvent, no async act wrappers, no jsdom quirks. A vitest run finishes in seconds because it’s just function calls.

// src/lib/cron.test.ts — this is what testing looks like
expect(summarizeCron('*/5 * * * *').description)
  .toBe('Every 5 minutes')

expect(nextRuns('0 9 * * 1-5', base, 3))
  .toHaveLength(3)

The islands are verified by the build — if they don’t render, astro build fails. If they render wrong, you see it in dev. The visual layer doesn’t need automated tests because it’s too thin to hide bugs.

Why this matters for velocity

When you need a new tool, you start with the lib. Write the logic, write the tests, get the edge cases right. Then wrap it in an island — that part takes 20 minutes because the island is just plumbing.

This also means libs are portable. If a tool ever moves to a CLI, a VS Code extension, or a standalone package, the lib goes with it unchanged. The React island was always disposable scaffolding.

The trade-off

You lose co-location. The logic and the UI live in different files, sometimes with different mental models. A developer reading the island has to jump to the lib to understand what summarizeCron actually does.

In practice, this hasn’t been a problem. The lib functions are named clearly, the types are explicit, and the islands are short enough to read in one screen. The trade-off buys testability, portability, and speed — worth it every time.