Chapter 4
Functions
How to organize interface code into small named functions, by refactoring the chapter 3 task board into helpers that calculate, change state, and update the screen.
Functions are how you organize behavior, and you usually start to need them at the moment your own code becomes hard to read. The task board from chapter 3 works. You can add a task, check it off, filter the list, see how many are left, and clear the finished ones. Now a teammate asks for one small change: stop people from adding the same task twice. Where does that rule go? It goes into the submit listener, which already reads the input, checks it, shows or clears an error, builds a task object, and resets the field. Then they ask whether the “Active” filter is definitely right. The only way to find out is to click through the interface, because the filtering logic sits in the middle of render, between createElement calls.
Here is a condensed version of the script chapter 3 ended with. The element lookups are the same as before, and the middle of render is shortened to comments.
// app.js (end of chapter 3, condensed)
const state = { tasks: [], filter: 'all' };
function render() {
const visibleTasks = state.tasks.filter((task) => {
if (state.filter === 'active') return !task.completed;
if (state.filter === 'completed') return task.completed;
return true;
});
taskList.replaceChildren();
for (const task of visibleTasks) {
// ...create an <li>, a checkbox, and a label, then append them
}
// ...show or hide the empty message and choose its text
// ...count active and completed tasks, write the count and the button label
// ...set aria-pressed on each filter button
}
addForm.addEventListener('submit', (event) => {
event.preventDefault();
const label = newTaskInput.value.trim();
if (label === '') {
newTaskError.textContent = 'Enter a task before adding it.';
newTaskError.hidden = false;
newTaskInput.setAttribute('aria-invalid', 'true');
newTaskInput.focus();
return;
}
newTaskError.textContent = '';
newTaskError.hidden = true;
newTaskInput.removeAttribute('aria-invalid');
state.tasks.push({ id: crypto.randomUUID(), label: label, completed: false });
newTaskInput.value = '';
render();
});
taskList.addEventListener('change', (event) => {
// ...find the task by id, flip completed, render, then put focus back
});
Nothing here is wrong, and chapter 3 deliberately put all the screen updates in one place. The problem is that the code doesn’t say what it does. render is about fifty lines that do five jobs, and the submit listener does six. To add the duplicate rule, you have to read the whole listener and find a safe spot in it. To test the filter, you have to run the whole page. This chapter takes the same script apart into small functions with names like addTask, getVisibleTasks, and renderTasks, so each listener reads like a sentence and each behavior can be found, tested, and changed on its own. The board won’t gain much in the way of features. What you gain is control over your own code.
What functions are for
A function is a named piece of behavior. You write it once, give it a name, and run it whenever you need that behavior by calling the name. That gives you three things at once: a place to put the code, a word for what the code does, and one spot to change it.
Interface code tends to fall into three kinds of function, and they line up with the questions from the introduction.
- Calculating functions take some information and return an answer. Which tasks are visible under this filter? Is this label acceptable? What should the count say? They don’t touch the page or the state.
- State-changing functions are where the answer to “what events can change that state?” lands.
addTaskandtoggleTaskCompleteeach make one specific change to the state object. - Screen-updating functions answer “what should update on the screen?”
renderTasksandupdateTaskCountread the state and write it to the DOM.
Event handlers then become short. A handler reads what happened, calls a state-changing function, and calls render. When the code is split this way, each kind of bug has an obvious place to look. A wrong count is a calculation problem, a task that won’t stay checked is a state problem, and a checkbox that looks wrong is a rendering problem.
Declarations, expressions, and arrow functions
JavaScript has three common ways to write a function. Here is the same count formatter written each way.
// A function declaration
function formatTaskCount(activeCount) {
return activeCount === 1 ? '1 task left' : `${activeCount} tasks left`;
}
// A function expression stored in a constant
const formatTaskCount = function (activeCount) {
return activeCount === 1 ? '1 task left' : `${activeCount} tasks left`;
};
// An arrow function stored in a constant
const formatTaskCount = (activeCount) =>
activeCount === 1 ? '1 task left' : `${activeCount} tasks left`;
You would only keep one of these in a real file, because declaring the same name twice is an error. All three do the same job when you call formatTaskCount(3). The differences that matter for interface code are small.
A declaration is hoisted, which means the browser registers it before running the rest of the file, so you can call it from a line above where it’s written. A function stored in a const isn’t available until the line that creates it has run, and calling it earlier throws a ReferenceError.
An arrow function without curly braces returns its expression automatically. Once you add curly braces, you have to write return yourself. That difference causes a very common bug, and you’ll meet it in the debugging challenge later in this chapter.
This chapter uses a simple convention: declarations for the named behaviors at the top level of the file, and arrow functions for the short callbacks you pass to filter, find, and some. Any consistent convention works. What matters is that a reader can find the named behaviors quickly.
Parameters and return values
A parameter is a name for information the function needs. A return value is the answer it hands back. Together they let a function work on whatever you give it, instead of reaching out for something specific.
Validation shows why this matters. The duplicate rule your teammate asked for needs the label and the existing tasks, and it needs to give back a message. A validator that takes both as parameters and returns the message looks like this:
function validateTaskLabel(label, tasks) {
if (label === '') return 'Enter a task before adding it.';
const isDuplicate = tasks.some(
(task) => task.label.toLowerCase() === label.toLowerCase()
);
if (isDuplicate) return `You already have a task called "${label}".`;
return '';
}
It returns an error message, or an empty string when the label is fine. Returning the message itself rather than true or false means the caller doesn’t need a second lookup to decide what to tell the person. some returns true as soon as its callback returns true for any item.
The function takes tasks as a parameter instead of reading state.tasks directly. That makes it easy to check in the console with made-up data, such as validateTaskLabel('Write brief', [{ id: '1', label: 'write brief', completed: false }]), and to reuse later with a different list.
Scope
Scope is the set of names a piece of code can see. A name declared at the top level of a module, like state or taskList, is visible to every function in that file. A name declared inside a function, such as a parameter or a const in the body, exists only inside that function, and only while it runs. A name declared inside a block, like the body of an if, exists only inside that block.
Inner functions can see the names around them. The callback in this function can read id because it’s written inside a function that has id as a parameter:
function findTask(id) {
return state.tasks.find((task) => task.id === id);
}
Scope is why the refactor can use two styles at once. State-changing and rendering functions read state and the element constants from the top of the module, because their whole job is to work with that shared state. Calculating functions receive what they need as parameters, so you can understand them without reading the rest of the file.
Scope is also why a module script doesn’t leak its names onto the page. Nothing in app.js becomes a global variable, so state isn’t available in the console unless you deliberately expose it, for example with window.state = state while you debug.
Pure functions and side effects
A pure function returns the same result for the same input and changes nothing outside itself. formatTaskCount and validateTaskLabel are pure, and so is the filter that used to live at the top of render:
function getVisibleTasks(tasks, filter) {
if (filter === 'active') return tasks.filter((task) => !task.completed);
if (filter === 'completed') return tasks.filter((task) => task.completed);
return tasks;
}
A side effect is anything a function does besides returning a value: changing the state object, writing to the DOM, moving focus, saving to storage. Interfaces need side effects, since an interface that never updates the screen isn’t much use. The aim is to keep them in a few functions with clear names instead of mixing them into every line.
createTask sits close to the line. It changes nothing outside itself, but it isn’t strictly pure, because crypto.randomUUID() returns a different id each time:
function createTask(label) {
return { id: crypto.randomUUID(), label, completed: false };
}
The shorthand { label } means { label: label }, and you’ll see it often.
The practical benefit of pure functions is that you can test them by calling them. If the “Active” filter shows the wrong tasks, you can call getVisibleTasks in the console with three sample tasks and look at the answer, without clicking through the interface.
Guard clauses
A guard clause is an early return that handles a case where a function shouldn’t continue. It keeps the main path of the function at the left margin instead of nesting it inside if blocks.
function toggleTaskComplete(id) {
const task = findTask(id);
if (!task) return;
task.completed = !task.completed;
}
Chapter 3’s handlers already use guards like if (!item) return; to ignore events from elements they don’t care about. Use guards for situations that can genuinely happen. Don’t use them to hide a bug you should fix.
Callbacks and event handlers
A callback is a function you pass to other code so that code can call it later. addEventListener, filter, find, and some all take callbacks. When you write tasks.filter((task) => !task.completed), filter calls your arrow function once per task and keeps the tasks where it returned true. Chapter 5 covers these array methods in detail.
Named functions make good event handlers, because the listener line then describes the wiring:
addForm.addEventListener('submit', handleAddTask);
There are no parentheses after handleAddTask. You’re passing the function itself, for the browser to call when a submit happens. Writing handleAddTask() would call it immediately while the page loads and pass its return value, undefined, to addEventListener. In this case the call would also throw, because there’s no event to call preventDefault on.
Naming
Good names are most of the benefit of a refactor. A few habits help:
- Start functions that change something with a verb:
addTask,clearCompletedTasks,renderTasks. - Start calculating functions with
get,count,format,validate, orcreate, so a reader knows they return something. - Name booleans as questions, like
isDuplicateorhasTasks. - Prefix event handlers with
handle, sohandleAddTaskis clearly called by an event andaddTaskis clearly not.
If you can’t name a function without using “and,” it’s probably doing two jobs. Other interface patterns follow the same habits: a setPressed(button, isPressed) helper for toggle buttons, a formatDate(date) helper for display text, or a helper that builds an accessible name such as “Delete Write brief” for an icon button.
Smallest version: one pure function
The smallest useful refactor pulls one calculation out of render. Replace the inline filter at the top of render with a call to getVisibleTasks from the section above:
// app.js (excerpt)
function render() {
const visibleTasks = getVisibleTasks(state.tasks, state.filter);
taskList.replaceChildren();
// ...the rest of render is unchanged
}
Reload the page and use the board: add three tasks, complete one, and try each filter. It should behave exactly as before. Then, in the console, temporarily expose the function with window.getVisibleTasks = getVisibleTasks at the bottom of app.js, reload, and run getVisibleTasks([{ id: 'a', label: 'Test', completed: true }], 'active'). You get an empty array without touching the page. That’s the whole technique in miniature: find one job, give it a name and parameters, and call it. The project repeats it for every job in the file.
Accessibility check
A refactor is supposed to change nothing a person can notice, so its accessibility check is a regression test. Before you start, write down what chapter 3’s board does for keyboard and screen reader users, and test each item again when you finish:
- Enter in the field adds a task, and an empty or duplicate label is announced as the field’s description with “invalid entry” or similar.
- After a failed add, focus is in the field.
- Space on a checkbox completes the task, and focus stays on that checkbox, or moves to the list heading if the filter hides it.
- Each filter button announces whether it’s pressed.
- The count is announced when it changes.
The item most likely to break is focus after completing a task. A tidy-looking handler might be:
function handleTaskChange(event) {
const item = event.target.closest('[data-task-id]');
if (!item) return;
toggleTaskComplete(item.dataset.taskId);
render();
}
It reads well, and it leaves out the focus fix. Test it with the keyboard: Tab to a checkbox, press Space, then press Tab. render replaced the checkbox you were on, so focus fell back to the page body, and a screen reader user loses their place. The fix is to keep that behavior and give it a name, so it can’t quietly disappear in the next refactor:
function restoreTaskFocus(id) {
const checkbox = document.getElementById(`task-${id}`);
if (checkbox) {
checkbox.focus();
return;
}
listHeading.focus();
}
The handler calls it right after render(). With a screen reader running, completing a task should announce the focused checkbox again, followed shortly by the updated count from the status region.
Debugging challenge: the Active filter shows nothing
Break the filter helper by giving one callback curly braces without a return:
function getVisibleTasks(tasks, filter) {
if (filter === 'active') return tasks.filter((task) => {
!task.completed;
});
if (filter === 'completed') return tasks.filter((task) => task.completed);
return tasks;
}
The symptom: “All” and “Completed” work, but “Active” shows the “No active tasks.” message even when you have unfinished tasks. There’s no error in the console.
Start with the function boundaries, because each function now has one job. Either the rendering is wrong or it was handed an empty array. Add a temporary console.log(getVisibleTasks(state.tasks, state.filter)) at the top of render and press “Active.” It logs [], so rendering is fine and the calculation is wrong.
Inside the calculation, the callback has curly braces, which makes it a block body. A block body only returns a value when it contains return. Without one, the callback returns undefined for every task, filter treats that as “no,” and every task is dropped. Fix it by removing the braces so the arrow returns its expression, or by writing return !task.completed; inside them. Then remove the console.log.
This works because…
This works because each function does one kind of job. Calculating functions such as getVisibleTasks and validateTaskLabel take what they need as parameters and return an answer without touching anything else, so you can test them in the console. State-changing functions such as addTask and toggleTaskComplete make one change to the state object. Rendering functions read that state and update the DOM. Each event handler reads the event, calls one state change, and calls render, so the screen still always matches the state, and every behavior is written in exactly one place.
Project: task board refactor
Refactor your chapter 3 task board into named functions without changing what it does. The one addition is the duplicate-label rule. Extract createTask, addTask, toggleTaskComplete, getVisibleTasks, renderTasks, updateTaskCount, and validateTaskLabel, plus whatever small helpers make the code read well.
The HTML and CSS from chapter 3 don’t change. Keep a copy of your chapter 3 app.js so you can compare the two versions side by side.
The refactored script
The file is grouped by kind of function, so a reader can find each one by asking what it does.
// app.js
const addForm = document.querySelector('[data-add-task-form]');
const newTaskInput = document.querySelector('[data-new-task-input]');
const newTaskError = document.querySelector('[data-new-task-error]');
const filterGroup = document.querySelector('[data-filter-group]');
const filterButtons = filterGroup.querySelectorAll('[data-filter]');
const listHeading = document.querySelector('[data-list-heading]');
const taskList = document.querySelector('[data-task-list]');
const emptyMessage = document.querySelector('[data-empty-message]');
const taskCount = document.querySelector('[data-task-count]');
const clearCompletedButton = document.querySelector('[data-clear-completed]');
const state = { tasks: [], filter: 'all' };
// Calculate
function createTask(label) {
return { id: crypto.randomUUID(), label, completed: false };
}
function validateTaskLabel(label, tasks) {
if (label === '') return 'Enter a task before adding it.';
const isDuplicate = tasks.some(
(task) => task.label.toLowerCase() === label.toLowerCase()
);
if (isDuplicate) return `You already have a task called "${label}".`;
return '';
}
function getVisibleTasks(tasks, filter) {
if (filter === 'active') return tasks.filter((task) => !task.completed);
if (filter === 'completed') return tasks.filter((task) => task.completed);
return tasks;
}
function countActiveTasks(tasks) {
return tasks.filter((task) => !task.completed).length;
}
function formatTaskCount(activeCount) {
return activeCount === 1 ? '1 task left' : `${activeCount} tasks left`;
}
function getEmptyMessage(tasks, filter) {
if (tasks.length === 0) return 'No tasks yet. Add one above.';
return `No ${filter} tasks.`;
}
// Change state
function findTask(id) {
return state.tasks.find((task) => task.id === id);
}
function addTask(label) {
state.tasks.push(createTask(label));
}
function toggleTaskComplete(id) {
const task = findTask(id);
if (!task) return;
task.completed = !task.completed;
}
function clearCompletedTasks() {
state.tasks = state.tasks.filter((task) => !task.completed);
}
function setFilter(filter) {
state.filter = filter;
}
// Update the screen
function createTaskItem(task) {
const item = document.createElement('li');
item.className = 'task';
item.dataset.taskId = task.id;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `task-${task.id}`;
checkbox.checked = task.completed;
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = task.label;
item.append(checkbox, label);
return item;
}
function renderTasks() {
const visibleTasks = getVisibleTasks(state.tasks, state.filter);
taskList.replaceChildren(...visibleTasks.map(createTaskItem));
emptyMessage.hidden = visibleTasks.length > 0;
if (visibleTasks.length > 0) return;
emptyMessage.textContent = getEmptyMessage(state.tasks, state.filter);
}
function updateTaskCount() {
const activeCount = countActiveTasks(state.tasks);
const completedCount = state.tasks.length - activeCount;
taskCount.textContent = formatTaskCount(activeCount);
clearCompletedButton.textContent = `Clear completed (${completedCount})`;
}
function updateFilterButtons() {
for (const button of filterButtons) {
const isCurrent = button.dataset.filter === state.filter;
button.setAttribute('aria-pressed', String(isCurrent));
}
}
function showTaskError(message) {
newTaskError.textContent = message;
newTaskError.hidden = message === '';
if (message) {
newTaskInput.setAttribute('aria-invalid', 'true');
} else {
newTaskInput.removeAttribute('aria-invalid');
}
}
function restoreTaskFocus(id) {
const checkbox = document.getElementById(`task-${id}`);
if (checkbox) {
checkbox.focus();
return;
}
listHeading.focus();
}
function render() {
renderTasks();
updateTaskCount();
updateFilterButtons();
}
// Handle events
function handleAddTask(event) {
event.preventDefault();
const label = newTaskInput.value.trim();
const message = validateTaskLabel(label, state.tasks);
showTaskError(message);
if (message) {
newTaskInput.focus();
return;
}
addTask(label);
newTaskInput.value = '';
render();
}
function handleTaskChange(event) {
const item = event.target.closest('[data-task-id]');
if (!item) return;
const id = item.dataset.taskId;
toggleTaskComplete(id);
render();
restoreTaskFocus(id);
}
function handleFilterClick(event) {
const button = event.target.closest('[data-filter]');
if (!button) return;
setFilter(button.dataset.filter);
render();
}
function handleClearCompleted() {
clearCompletedTasks();
render();
}
addForm.addEventListener('submit', handleAddTask);
taskList.addEventListener('change', handleTaskChange);
filterGroup.addEventListener('click', handleFilterClick);
clearCompletedButton.addEventListener('click', handleClearCompleted);
render();
renderTasks now builds the list with map and passes the result to replaceChildren in one call, where chapter 3 emptied the list and appended items in a loop. Both produce the same elements. Because function declarations are hoisted, you could move the four addEventListener lines up to just below the element lookups, above all the function declarations, and they would still work.
The handlers are now three to eight lines each, and you can read handleAddTask from top to bottom as a description of what adding a task means: read the label, validate it, show the result, stop if it’s invalid, add it, clear the field, render. Chapter 6 covers when a form should show and clear errors as someone types, and builds a complete pattern for it.
Done means
- Every listener passes a named handler, and every handler is a few lines long.
- Each of the seven named functions exists and does only what its name says.
-
getVisibleTasks,validateTaskLabel,countActiveTasks, andformatTaskCountread no outside state, and you’ve called each one from the console with sample data. - Adding a task whose label matches an existing one, ignoring case, shows the duplicate message, connected to the field and announced by a screen reader.
- Every item in the accessibility check above behaves exactly as it did in chapter 3, tested with the keyboard and a screen reader.
- You’ve compared the old and new versions side by side and confirmed the board behaves the same in every case you tried.
- You’ve written a short “this works because…” paragraph for your version, naming which functions calculate, which change state, and which update the screen.
Chapter 5 takes renderTasks further. It covers building elements from templates, updating items in place so focus never has to be restored, and why textContent keeps user input from becoming code.
Found an error? Report it.