Functional Programming Foundations for React
Apply core functional-programming concepts — pure functions, immutability, higher-order functions, composition, and the map/filter/reduce trio — to write predictable, testable React state updates, and correctly identify and isolate side effects.
React's component model — a function of props and state that returns UI — is itself a functional-programming idea: rendering is meant to behave like a pure function. Nearly every idiom that makes React code predictable (avoiding direct state mutation, using .map() to render lists, updater functions in setState, composing hooks) is functional programming in practice, even when nobody calls it that. This lesson makes the underlying concepts explicit — pure functions, immutability, higher-order functions, composition, map/filter/reduce, and side effects — and shows exactly how each one shapes correct React state updates.
Pure functions and immutability
A pure function's output depends only on its inputs, and calling it produces no observable change to anything outside itself — no mutating an argument, no writing to a variable outside its scope, no network call, no Math.random() or Date.now() inside it. Given the same arguments, a pure function always returns the same result. React's rendering model expects your component functions and reducers to behave this way: React may call a component function multiple times, skip a call, or re-run it, and none of that should be observable other than through its return value.
Immutability means never changing a data structure in place — instead, you produce a new one with the desired change applied, leaving the original untouched. This matters enormously in React because state updates are detected by reference comparison: useState and useReducer re-render when the new state value is a *different reference* than the old one. Mutating an object or array in place and calling setState with the same reference means React sees 'no change' and may not re-render at all, even though the data visibly changed.
The practical habit: when updating an array, use .map(), .filter(), or the spread operator ([...arr, newItem]) to produce a new array rather than .push() or index assignment. When updating an object, use { ...obj, field: newValue } rather than obj.field = newValue.
Higher-order functions and composition
A higher-order function either takes a function as an argument, returns a function, or both. Array.prototype.map is a higher-order function (it takes a transform function); a custom hook like useDebounce(callback, delay) that returns a debounced version of callback is also a higher-order function. React itself leans heavily on this: components can be higher-order functions when they take a render or children function as a prop (as seen with render props in Module ED), and Higher-Order Components (HOCs) like withAuth(Component) are functions that take a component and return a new, enhanced component.
Composition is the practice of building complex behavior by combining small, single-purpose functions rather than writing one large function that does everything. compose(f, g, h)(x) is equivalent to f(g(h(x))) — each function does one narrow thing, and the pipeline reads top-to-bottom (or right-to-left, depending on convention) as a sequence of transformations. In React, this shows up as chaining .filter().map() calls, composing multiple custom hooks inside one feature hook, or wrapping a component in several HOCs.
The payoff of both ideas together is that small, pure, composable pieces are each independently testable and reusable — a formatCurrency function or a useDebounce hook can be unit-tested in complete isolation from any component that uses it.
map, filter, reduce as the core data-transformation vocabulary
These three array methods cover the overwhelming majority of list-transformation needs in React and, crucially, all three return new arrays or values without mutating the original — which is exactly the immutability discipline React needs. .map() transforms each element into a new element (same length in, same length out) — the standard way to turn an array of data into an array of JSX elements. .filter() selects a subset of elements matching a predicate, producing a shorter (or equal-length) array — the standard way to implement search, filtering UI, or removing an item from a list immutably (items.filter(item => item.id !== idToRemove)).
.reduce() folds an array down into a single accumulated value of any shape — a sum, an object keyed by some field, a nested tree, or even another array. It is the most general of the three (map and filter can both be implemented in terms of reduce) and the least readable when overused, so it's best reserved for genuine accumulation, not as a replacement for a simple map or filter.
Chaining them (items.filter(...).map(...)) reads as a pipeline: first narrow down which items matter, then transform each one — this order is usually both more efficient (filtering first means mapping fewer items) and more readable than the reverse.
• .map(fn) — transform each element; length in === length out. • .filter(predicate) — keep only matching elements; length out <= length in. • .reduce(fn, initial) — fold everything down into one accumulated value of any shape. • Chain filter before map when both are needed: fewer items to transform.
Side effects and functional state updates
A side effect is anything a function does that's observable outside its own return value: a network request, writing to localStorage, mutating a variable declared outside the function, logging, or subscribing to an event. Side effects are not forbidden — React apps obviously need to fetch data and talk to the DOM — but functional style says to isolate them at clearly marked boundaries (React's useEffect, event handlers) rather than scattering them inside otherwise-pure rendering logic or reducer functions.
This is why reducers passed to useReducer must stay pure — no fetch calls, no mutating the input state, no Date.now() inside them — while useEffect is exactly the sanctioned place for side effects to live, kept separate from the pure state-transition logic. A reducer that calls fetch internally breaks React's ability to predict and test it in isolation; the correct shape dispatches an event when the fetch starts, and a separate useEffect performs the actual fetch, dispatching further events as it resolves (exactly the pattern from the state-machine lesson).
'Functional state updates' specifically refers to passing an updater function to setState — setCount(prev => prev + 1) — instead of computing the next value from a captured variable — setCount(count + 1). The updater form is pure: it receives the latest state as an argument and returns the next state, with no dependency on a possibly-stale closure variable. This matters most when multiple updates can be queued in the same render pass (e.g., calling the setter twice, or inside a rapidly-firing event), where the closure-based form can silently use stale data.
Functional array operations and updater-function state
None of .map, .filter, or .reduce mutate their source array — each returns a brand-new array or value. setCount(prev => prev + 1) is safe to call multiple times in a row; setCount(count + 1) called twice in the same handler only increments once, because both calls close over the same stale count.
// map: transform each element, same length out
const doubled = [1, 2, 3].map((n) => n * 2); // [2, 4, 6]
// filter: keep matching elements, immutably remove an item
const remaining = items.filter((item) => item.id !== idToRemove);
// reduce: fold into a single accumulated value
const total = cartItems.reduce((sum, item) => sum + item.price * item.qty, 0);
// composition: small pure functions combined into a pipeline
const activeUserNames = users
.filter((u) => u.isActive)
.map((u) => u.name);
// functional state update: pure, no stale-closure risk
setCount((prevCount) => prevCount + 1);
// immutable object update
setUser((prevUser) => ({ ...prevUser, name: newName }));Code Examples
applyDiscountImpure is hard to test (you must inspect a shared log variable and worry about the current time) and dangerous to call from React state, since it silently mutates whatever array you pass it — including possibly your current state array. applyDiscountPure is trivially testable (expect(applyDiscountPure([{price:100}], 10)).toEqual([{price:90}])) and safe to use directly inside a setState updater.
return cart.map((item) => ({ ...item, price: item.price * (1 - percent / 100) }));
Common mistake: Calling applyDiscountImpure(cartState, 10) and then setCartState(cartState) — React sees the *same* array reference and may not re-render at all, even though the prices visibly changed.
Every mutation-shaped operation — add, toggle, remove — is implemented as producing a brand-new array via spread, .map(), or .filter(), and every setter uses the functional updater form (prev => ...) so it's always working from the true latest state, not a captured closure value. remainingCount folds the array down to a single number with .reduce().
setTodos((prev) => prev.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)));
computeTotalRevenue reads as a clear pipeline: filter to paid orders, map to revenue lines, reduce to a total. Each named step (isPaid, toRevenueLine, sumRevenue) can be tested with a one-line assertion, and any of them can be reused elsewhere without dragging the whole pipeline along.
return sumRevenue(orders.filter(isPaid).map(toRevenueLine));
searchReducer can be tested with plain input/output assertions and no mocking whatsoever, because it is 100% pure. The network call — the side effect — lives exclusively inside useEffect, which is the one place React expects and manages side effects (including cleanup via the cancelled flag).
function searchReducer(state: SearchState, event: SearchEvent): SearchState {
// IMPURE — mutates the input, and its output depends on external state (the Date).
let discountAppliedLog: string[] = [];
function applyDiscountImpure(cart: { price: number }[], percent: number): void {
cart.forEach((item) => {
item.price = item.price * (1 - percent / 100); // mutates caller's array!
});
discountAppliedLog.push(`Applied ${percent}% at ${new Date().toISOString()}`); // side effect
}
// PURE — same inputs always produce the same output, nothing outside is touched.
function applyDiscountPure(
cart: { price: number }[],
percent: number
): { price: number }[] {
return cart.map((item) => ({
...item,
price: item.price * (1 - percent / 100),
}));
}interface TodoItem { id: number; text: string; done: boolean }
function useTodos(initial: TodoItem[]) {
const [todos, setTodos] = React.useState<TodoItem[]>(initial);
const addTodo = (text: string) => {
setTodos((prev) => [...prev, { id: Date.now(), text, done: false }]);
};
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo))
);
};
const removeTodo = (id: number) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
const remainingCount = React.useMemo(
() => todos.reduce((count, todo) => (todo.done ? count : count + 1), 0),
[todos]
);
return { todos, addTodo, toggleTodo, removeTodo, remainingCount };
}interface Order { id: string; total: number; status: "paid" | "refunded" | "pending" }
const isPaid = (order: Order): boolean => order.status === "paid";
const toRevenueLine = (order: Order): { id: string; revenue: number } => ({
id: order.id,
revenue: order.total,
});
const sumRevenue = (lines: { revenue: number }[]): number =>
lines.reduce((sum, line) => sum + line.revenue, 0);
// Each function is small, pure, and independently unit-testable.
function computeTotalRevenue(orders: Order[]): number {
return sumRevenue(orders.filter(isPaid).map(toRevenueLine));
}
// A generic compose helper, for when the pipeline itself needs to be reusable:
function compose<A, B, C>(f: (b: B) => C, g: (a: A) => B): (a: A) => C {
return (a: A) => f(g(a));
}// Pure — the reducer never calls fetch, never touches the DOM, never reads Date.now().
type SearchState =
| { status: "idle" }
| { status: "loading"; query: string }
| { status: "results"; query: string; items: string[] };
type SearchEvent =
| { type: "SEARCH_STARTED"; query: string }
| { type: "SEARCH_COMPLETED"; items: string[] };
function searchReducer(state: SearchState, event: SearchEvent): SearchState {
switch (event.type) {
case "SEARCH_STARTED":
return { status: "loading", query: event.query };
case "SEARCH_COMPLETED":
return state.status === "loading"
? { status: "results", query: state.query, items: event.items }
: state;
default:
return state;
}
}
// Impure — the ONE place the actual network side effect is allowed to live.
function useSearch(query: string) {
const [state, dispatch] = React.useReducer(searchReducer, { status: "idle" });
React.useEffect(() => {
if (!query) return;
let cancelled = false;
dispatch({ type: "SEARCH_STARTED", query });
searchApi(query).then((items) => {
if (!cancelled) dispatch({ type: "SEARCH_COMPLETED", items });
});
return () => { cancelled =
…Where purity ends and side effects begin
Keep everything above the boundary pure and testable without mocks; push everything that touches the outside world down into useEffect or an event handler, and have it communicate back up only by dispatching events.
Pure zone (no I/O, same input -> same output, fully unit-testable)
┌───────────────────────────────────────────────┐
│ component render function │
│ reducer functions (useReducer) │
│ map / filter / reduce pipelines │
│ small helper functions (formatCurrency, etc.) │
└───────────────────────────────────────────────┘
│
│ dispatch(event) / return JSX
▼
Effect boundary (React explicitly manages this)
┌───────────────────────────────────────────────┐
│ useEffect(() => { fetch(...); subscribe(...) })│
│ event handlers (onClick calling an API) │
└───────────────────────────────────────────────┘
│
▼
Outside world: network, localStorage, DOM APIs, timersImportant Rules
Never mutate a value passed into a reducer, a .map()/.filter() callback, or a component's props — always derive and return a new value.
Prefer setState(prev => ...) over setState(currentClosureValue + delta) whenever the next state depends on the previous state.
Keep reducers 100% pure: no fetch, no Date.now(), no Math.random(), no mutation — push all of that into useEffect or event handlers.
Chain .filter() before .map() when you need both, to avoid transforming items you're about to discard.
Reach for .reduce() only for genuine accumulation; a plain .map() or .filter() is more readable when that's all you need.
Where this shows up in production
Redux and useReducer both require pure reducers as a hard rule, not a suggestion — Redux DevTools' time-travel debugging and React's Strict Mode double-invocation of reducers in development both rely on reducers being safe to call repeatedly with no side effects. Libraries like Immer exist specifically to make the *ergonomics* of immutable updates nicer (letting you write mutation-looking syntax that Immer converts into a real immutable update under the hood) without giving up the underlying guarantee. Data-heavy dashboards lean on .filter().map().reduce() pipelines constantly for computing derived, chart-ready data from raw API responses — and memoizing those pipelines with useMemo is only safe to do because they're pure: memoization assumes 'same input produces same output,' which is exactly the pure-function contract.
state.items.push(newItem); setItems(state.items);
setItems((prev) => [...prev, newItem]);
.push() mutates the existing array in place and returns its new length, not a new array — setItems then receives the *same* array reference, so React may skip the re-render entirely even though the array's contents changed.
setCount(count + 1); setCount(count + 1); // intending to add 2 setCount((prev) => prev + 1); setCount((prev) => prev + 1);
Both calls to the closure-based version read the same stale count from this render, so the count only increases by 1, not 2. The updater-function form always receives the true latest value, even across multiple queued updates.
function statsReducer(state, event) { fetch('/api/log', { method: 'POST' }); return { ...state, count: state.count + 1 }; }
Keep the reducer pure; dispatch the increment, then perform the logging fetch separately inside a useEffect that runs when state.count changes.
A reducer with a side effect inside it breaks the guarantee that calling it twice with the same arguments is safe and observable-free — React (and StrictMode's intentional double-invoke in development) can call reducers more than once, which would fire the network request twice.
Why does items.push(newItem); setItems(items); often fail to trigger a re-render in React? A. push() is asynchronous and hasn't finished by the time setItems runs. B. React only re-renders on the first state update per component lifetime. C. push() mutates the array in place, so setItems receives the same reference React already has, and reference-equal state is treated as unchanged. D. setItems requires an updater function and throws when given a plain value.
C. push() mutates the array in place, so setItems receives the same reference React already has, and reference-equal state is treated as unchanged. — React's state update detection is reference-based. Mutating in place keeps the same array reference, so React can conclude nothing changed, skipping the re-render even though the contents differ.
Why must reducers passed to useReducer stay pure, with no fetch calls or mutations inside them? A. Pure functions run faster in V8 than impure ones. B. TypeScript won't compile a reducer that calls fetch. C. React (including Strict Mode in development) may call a reducer more than once with the same arguments, and tools like time-travel debugging rely on reducers being side-effect-free and deterministic. D. useReducer only accepts arrow functions, not functions with side effects.
C. React (including Strict Mode in development) may call a reducer more than once with the same arguments, and tools like time-travel debugging rely on reducers being side-effect-free and deterministic. — Purity is what makes it safe for React to call a reducer repeatedly or out of order, and it's what tools like Redux DevTools' time-travel and React Strict Mode's double-invocation rely on. An impure reducer breaks those guarantees, e.g., firing a network request twice.
Which array method is the most general of map/filter/reduce, in the sense that the other two can be implemented in terms of it? A. map B. filter C. reduce D. forEach
C. reduce — reduce() can fold an array into any shape, including reimplementing map (accumulate a transformed array) or filter (accumulate a subset) — which is also why it should be reserved for genuine accumulation rather than used as a default replacement for the more readable map/filter.
Given an array of Order { id: string; total: number; category: 'electronics' | 'clothing' | 'other' }, write a single composed pipeline (using filter/map/reduce) that computes the total revenue from 'electronics' orders only, without mutating the input array. Then rewrite the same logic as three small named pure functions composed together.
const electronicsRevenue = orders.filter(o => o.category === 'electronics').reduce((sum, o) => sum + o.total, 0); // As composed named functions: const isElectronics = (o: Order) => o.category === 'electronics'; const sumTotals = (orders: Order[]) => orders.reduce((sum, o) => sum + o.total, 0); function computeElectronicsRevenue(orders: Order[]): number { return sumTotals(orders.filter(isElectronics)); } filter by category first, then reduce directly to a sum — you don't need a separate map step here since you're not reshaping each order, just extracting .total.