← Concepts & practices
Concept Design principles and language mechanisms

Pure functions & side effects

Decide in one place, act in another.

You already follow the rule that rendering shouldn’t change anything. Let’s follow a trial banner that works out its message and records that it was shown in the same function, until the analytics count three impressions for one view and the tests only pass at certain hours.

TypeScriptGo One trial banner, two implementations.

01 / The idea

Working out the banner and recording it together is a fair start.

You’re building the dashboard for a SaaS product with a free trial. trialBanner(account) works out how many days are left, picks a message, and records a trial_banner_shown event so the growth team can see who saw it. One function, one call when the page loads, and everything about the banner in one place.

Read the first bannerTypeScript · the version this lesson starts from
banner.ts
// The first version: work out the banner, and record that it was shown.
export function trialBanner(account: Account): Banner {
	const today = new Date().toISOString().slice(0, 10); // Reads the clock.
	const daysLeft = daysBetween(today, account.trialEndsOn);
	track({ name: 'trial_banner_shown', accountId: account.id, daysLeft }); // Records an impression.
	if (daysLeft < 0) return { daysLeft, tone: 'ended', message: 'Your trial has ended' };
	if (daysLeft === 0) return { daysLeft, tone: 'urgent', message: 'Your trial ends today' };
	const message = `${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} left in your trial`;
	return { daysLeft, tone: daysLeft <= 3 ? 'urgent' : 'info', message };
}

Go’s version calls time.Now() and appends to a package-level event list the same way. Both languages meet again at bannerOn in section 02.

Then the banner moves into a component. It’s worked out on every render, and the growth dashboard shows three impressions for each visit. The test for “Your trial ends today” passes in the afternoon and fails after midnight UTC. And a customer in California writes in at 6 pm to say the banner already counts tomorrow.

A pure function gets everything it depends on through its parameters, and does nothing but return a value. Reading the clock is an input, even when it isn’t a parameter; recording an event is an output, even when nothing is returned. Put the decision in a pure function and the reads and writes in a thin shell around it, and the decision can run any number of times, in any test, at any hour. React’s documentation puts the rule in four words: “Same inputs, same output.”

Section 05 builds a trial banner that updates at midnight and records one impression a day, in React and Svelte.

02 / See the shape

Pass in what it reads, and return what it decides.

The basic form is a pure function: the day is a parameter, and the banner is the only result. In the wild adds the shell that reads the clock and records the event, once each. At the call site works out four days of banners and shows one recorded event.

Both languages produce the same messages, tones, and event count.

A pure function. bannerOn takes the trial end date and the day as parameters, and only returns a banner.

TypeScriptReading
banner.ts
// Everything the banner depends on is a parameter, and all it does is return a value.
export function bannerOn(trialEndsOn: string, today: string): Banner {
	const daysLeft = daysBetween(today, trialEndsOn);
	if (daysLeft < 0) return { daysLeft, tone: 'ended', message: 'Your trial has ended' };
	if (daysLeft === 0) return { daysLeft, tone: 'urgent', message: 'Your trial ends today' };
	const message = `${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} left in your trial`;
	return { daysLeft, tone: daysLeft <= 3 ? 'urgent' : 'info', message };
}
GoAlongside
banner.go
// BannerOn takes everything the banner depends on as a parameter, and only returns a value.
func BannerOn(trialEndsOn, today string) Banner {
	daysLeft := DaysBetween(today, trialEndsOn)
	switch {
	case daysLeft < 0:
		return Banner{daysLeft, "ended", "Your trial has ended"}
	case daysLeft == 0:
		return Banner{daysLeft, "urgent", "Your trial ends today"}
	}
	unit, tone := "days", "info"
	if daysLeft == 1 {
		unit = "day"
	}
	if daysLeft <= 3 {
		tone = "urgent"
	}
	return Banner{daysLeft, tone, fmt.Sprintf("%d %s left in your trial", daysLeft, unit)}
}
Reading the TypeScriptHidden inputs and a small interface

The first version needs vi.useFakeTimers and vi.setSystemTime before its test means anything. bannerOn needs a date string. new Date().toISOString() is also why the customer in California saw tomorrow: MDN notes that “the timezone is always UTC.”

Clock and Analytics are one-method interfaces, so a test passes an object literal and counts what happened.

Reading the GoInterfaces at the edge

The first version’s hidden input is time.Now(), and its hidden output is the package-level Sent slice. BannerOn takes two strings and returns a Banner value.

The shell takes Clock and Analytics interfaces. The example satisfies them with a fixedClock string type and a small recorder, the same way a test would.

03 / Follow the calls

Watch what each call reads and records.

Five steps, each running the lesson’s functions. On the left are the calls and what they returned; on the right is everything they touched outside themselves. Before each step, guess how many events it records.

In Try it, pick a day and call each version, including the one that reads your real clock.

Pure functions

What did the function touch?

The same argument, two answers. trialBanner(account) at 23:59 UTC returns “4 days left in your trial”; trialBanner(account) at 00:01 UTC returns “3 days left in your trial”. Clock reads: 2. Events recorded: 2. Nothing about the argument changed. The function read the clock, which is an input you can’t see in the call.

01/ 05
Call trialBanner either side of midnight

Two minutes apart, two answers.

trialBanner(account) says 4 days left at 23:59 UTC and 3 days left at 00:01. It read the clock both times.

Reduced motion: choose a scene to see its completed state.

Read this scene

trialBanner(account) says 4 days left at 23:59 UTC and 3 days left at 00:01. It read the clock both times.

The same argument, two answers. trialBanner(account) at 23:59 UTC returns “4 days left in your trial”; trialBanner(account) at 00:01 UTC returns “3 days left in your trial”. Clock reads: 2. Events recorded: 2. Nothing about the argument changed. The function read the clock, which is an input you can’t see in the call.

Watch restarts when you return. Step through keeps your selected step. Try it starts with no calls each time you open it.

What a pure core buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Answers you can predict
bannerOn('2026-09-17', '2026-09-14') says 3 days left, whenever and however often it runs.
Tests without a fake clock
The last day, the day after, and one day left are each a call with a date.
Safe to run again
Ten more calculations, as re-renders do, record nothing.
Effects you can count
showTrialBanner reads the clock once and records one event.
Reuse without surprises
A reminder email can call bannerOn without recording impressions nobody saw.

The review words are pure function, side effect, hidden input for the clock the call doesn’t mention, referential transparency for being able to replace a call with its result, and functional core, imperative shell for the split. Section 08 covers what they cost.

04 / Try a decision

A cache that remembers yesterday.

bannerOn is pure, so someone cached it. The code is in cached.ts, and the lesson’s tests pin what happens.

The next morning, what does the banner say?

To avoid working the banner out on every render, someone wrapped bannerOn in cachedBanner(account, today), which stores the result in a module-level Map keyed by account.id. The tab stays open overnight: it called cachedBanner(account, '2026-09-14') yesterday and calls cachedBanner(account, '2026-09-15') now. The trial ends on the 17th.

05 / Give it a real job

A banner that’s right at midnight and counted once a day.

In the real dashboard, people leave the tab open for days. The banner has to change at local midnight without a reload, record one impression per day it’s seen, and let people dismiss the early reminders for the rest of the day.

Clock

Read by the shell

The local day, updated by a timer at midnight.

Banner

Worked out on every render

Pure, so running it again costs nothing but time.

Impression

Recorded after rendering

Once for each day the banner is visible.

The example leaves out saving the dismissal, trial dates that come from the server, and the time zone a billing system uses to end a trial.

Build UIs?Every component you write is asked to render without side effects, and one day an analytics count or a midnight rollover shows you why.

Where it already is in your components

React asks for exactly this split. Its guide says a pure function “minds its own business” and gives the “same inputs, same output”, and that “By calling the component functions twice, Strict Mode helps find components that break these rules.” The textbook banner works out its message while rendering and records the impression in useEffect. React’s own analytics example runs twice in development, and the docs say to keep it: “In production, there will be no duplicate visit logs.”

Svelte states the same rule for derived values: “The expression inside $derived(...) should be free of side-effects. Svelte will disallow state changes (e.g. count++) inside derived expressions.” The Svelte banner derives its message and records the impression in $effect.

When you have to own it

Now it’s the banner that stays open overnight. The only clock read is a small shell that keeps the local day and sets a timer for the next midnight. bannerOn runs from that day on every render, and the impression effect depends on the day, so it records once per day the banner is visible.

Dismiss is a side effect too, and it lives in the click handler. React’s guide says event handlers “don’t need to be pure.”

trial-banner.ts
export type Banner = { daysLeft: number; tone: 'info' | 'urgent' | 'ended'; message: string };

// Pure: the banner for a trial end date on a given calendar day (YYYY-MM-DD).
export function bannerOn(trialEndsOn: string, today: string): Banner {
	const daysLeft = Math.round(
		(Date.parse(`${trialEndsOn}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000
	);
	if (daysLeft < 0) return { daysLeft, tone: 'ended', message: 'Your trial has ended' };
	if (daysLeft === 0) return { daysLeft, tone: 'urgent', message: 'Your trial ends today' };
	const message = `${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} left in your trial`;
	return { daysLeft, tone: daysLeft <= 3 ? 'urgent' : 'info', message };
}

// Pure: the calendar day a moment falls on where the person is, not in UTC.
export function localDay(now: Date): string {
	const month = String(now.getMonth() + 1).padStart(2, '0');
	const day = String(now.getDate()).padStart(2, '0');
	return `${now.getFullYear()}-${month}-${day}`;
}

// Pure: milliseconds from a moment until the next local midnight.
export function msUntilNextDay(now: Date): number {
	const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
	return midnight.getTime() - now.getTime();
}
components/analytics.ts
// Stub for the panes: the app's analytics client.
export function track(name: string, properties: Record<string, string | number>): void {
	void name;
	void properties;
}

A banner worked out during rendering from its props, with the impression recorded in useEffect or $effect.

ReactAlready in your code
TrialBanner.tsx
import { useEffect } from 'react';
import { track } from './components/analytics';
import { bannerOn } from './trial-banner';

type Props = { accountId: string; trialEndsOn: string; today: string };

export function TrialBanner({ accountId, trialEndsOn, today }: Props) {
	// Rendering only calculates, so React can call this as often as it likes.
	const banner = bannerOn(trialEndsOn, today);

	// Recording the impression is a side effect, so it runs after rendering, not during it.
	useEffect(() => {
		track('trial_banner_shown', { accountId, daysLeft: banner.daysLeft });
	}, [accountId, banner.daysLeft]);

	return (
		<p role="status" data-tone={banner.tone}>
			{banner.message}
		</p>
	);
}

06 / Recognize it elsewhere

Anywhere a decision sits next to a read or a write.

You’ve met all of these. For each one, find the pure part and the effect at its edge.

Familiar code, its pure part, and the effect at its edge
Where you’ve seen itThe pure partThe effect at the edge
A reducer in useReducerThe next state from state and actionReact storing it and re-rendering
A component and its effectsRendering from props and stateSubscriptions, timers, analytics
toSorted() and sort()toSorted returns a new arraysort changes the one you passed
A Go HTTP handlerWorking out the responseReading the request, writing the ResponseWriter
A test that fakes the clock or Math.randomWhatever the function decidesA hidden input the test had to replace

When a test has to fake something the call doesn’t mention, you’ve found a hidden input. When a function can’t run twice safely, you’ve found an effect.

07 / Already in your toolbox

Your frameworks already ask for pure calculations.

Three places to look. For each one, find what must be pure and where effects go.

React · Keeping Components Pure

What makes a function pure, why Strict Mode renders twice in development, and why side effects belong in event handlers.

Read the guide ↗

React · useReducer

A reducer “must be pure”, and in Strict Mode React calls it twice “to help you find accidental impurities.”

Read the reference ↗

MDN · Array.prototype.toSorted

The copying version of sort(), which leaves the original array unchanged, and has worked across browsers since July 2023.

Read the reference ↗
A useful counterexample: a function whose job is the effectWhen splitting adds nothing

saveDismissal(accountId) writes one row and returns. There’s no decision to pull out, so a pure core would be an empty function with a name.

08 / The parts to watch

Purity is easy to lose and cheap to fake.

These are the places it still goes wrong.

A cache is a hidden input

Caching a pure function keeps it pure only if the key covers everything the function reads. Keyed by account id alone, the banner stays at yesterday’s count, and it would miss a trial extension too.

Time zones are inputs too

toISOString() gives the UTC day. Decide whose calendar the banner follows, and pass that day in.

Changing an argument is an output

sort() on a list you were given changes the caller’s list. toSorted() or slices.Clone keep the change inside.

Pure doesn’t mean cheap

Running a pure function on every render is safe, not free. Measure before caching, and key any cache by everything it reads.

The shell still needs tests

Moving effects to the edge makes them few, not correct. Check that the shell reads the clock once and records once, with fakes.

A double run in development is a check

React’s Strict Mode repeats rendering and effects on purpose. Don’t silence it; make the code safe to run twice.

09 / Make the call

What would you have to change tomorrow?

Give both banners a plausible change and follow the work it creates.

How a change affects one function that does everything and a pure core with a shell
The changeOne functionPure core and shell
A script that prints one bannerShort and fine.Extra functions for one call.
Render it in a componentAn impression per render.One per view, from an effect.
Test “Your trial ends today”Fake the system clock.Pass the date.
Reuse it in a reminder emailRecords impressions nobody saw.Call bannerOn.
Switch analytics vendorsEdit the calculation’s file.Edit the shell.

Split the decision from its effects when it runs more than once, gets reused, or needs tests at particular times. A banner in a component is the moment.

Keep one function when it runs once and its effect is the whole job.

The question I’d leave beside the code is: what does this function read that isn’t a parameter, and what does it change that isn’t its return value?

10 / Take the idea with you

Explain the triple impressions without saying “pure.”

“Working out the banner also recorded that it was shown, and the page works the banner out on every render. We split them: the banner is calculated from the day we pass in, and the impression is sent once, after it’s shown.” In a review, the words are pure function, side effect, and functional core, imperative shell.

Before moving on, jot down why the impressions tripled, why the test depended on the hour, and one function in your own code that reads the clock or records something while it decides.

Connections to follow nextRelated lessons

Take the banner into your editor. Fix cachedBanner by keying it on what the banner reads, the trial end date and the day, and write the test that would have caught it.

Back to Concepts & practices →