Start with one responsibility

A reusable component is not simply a function that creates elements. It is a small part of an interface with a defined job, a way to receive input, and predictable behavior. A counter, for example, can display a value and change it when a user presses a button. It does not need to decide where it appears on the page or how the rest of the application stores its data.

Before writing code, describe what the component receives, what it owns, and what it reports. A counter might receive an initial value and an optional change callback. It can own its current value and the elements used to display and change it. If another part of the page needs to know the value changed, the callback provides that information without giving the counter control over unrelated elements.

This distinction helps keep reuse practical. A component with many unrelated options may be harder to reuse than two smaller components with clear purposes. Add configuration when it represents a genuine variation in the component’s job, not just to accommodate every page where it might appear.

Give the component a DOM boundary

A DOM boundary is the element within which a component renders and handles its own interface. Let the caller choose a host element, then limit the component’s queries and updates to that host. Avoid looking up a generic class name across the whole document: another instance may use the same class, causing one component to change the other.

Decide what happens to the host’s existing children. One simple contract is that mounting replaces them. That rule is easy to understand, but it means the caller should not put content in the host that must survive. The component should also avoid changing the host’s parent, siblings, or document-wide styles. Those belong to the page or to a higher-level component.

Think about ownership in both directions. The page owns where the host is placed and when it is removed. The component owns the elements it creates inside that host. When those responsibilities are explicit, multiple instances can coexist without relying on unique IDs or global selectors.

Build a small component with an explicit contract

The following example runs in a modern browser document. Place the code in a JavaScript module loaded by that document; it creates two hosts, so no pre-existing page markup is needed. Each counter starts from its own value and updates only its own host.

function createCounter(host, { initialValue = 0, onChange = () => {} } = {}) { let value = initialValue; const button = document.createElement('button'); button.type = 'button'; host.replaceChildren(button); function render() { button.textContent = `Count: ${value}`; } function handleClick() { value += 1; render(); onChange(value); } button.addEventListener('click', handleClick); render(); return { getValue() { return value; }, destroy() { button.removeEventListener('click', handleClick); host.replaceChildren(); } }; } const firstHost = document.createElement('div'); const secondHost = document.createElement('div'); document.body.append(firstHost, secondHost); const first = createCounter(firstHost, { initialValue: 2 }); createCounter(secondHost);

The returned object exposes only operations a caller may need: reading the value and cleaning up. The component does not expose its button as part of its public contract. That leaves room to change its internal markup later without requiring callers to change. The two instances have separate state because each call creates its own value, button, and event handler.

Treat events and cleanup as part of the design

A component needs a deliberate way to communicate outward. A callback is sufficient for a small example: it passes a value to the caller without making assumptions about what the caller will do with it. For a larger interface, a custom event may be useful when several listeners need to respond. Whichever approach you choose, document when notifications happen and what data they provide.

Event listeners also deserve an ownership rule. The counter adds a listener to a button it creates, then removes that listener in destroy. The caller can invoke destroy before reusing the host. If a component starts timers, observers, subscriptions, or listeners on document or window, its cleanup method should release those too. Removing visible elements alone does not describe how those other resources are managed.

Avoid silently mounting the same component twice into one host. In a larger implementation, you might reject a second mount or require callers to destroy the first instance. A clear rule is more reliable than hoping the old instance will no longer receive events.

Check reuse, behavior, and accessibility

A quick reuse check is to mount two instances with different initial values and interact with each separately. Verify that each host changes only when its own button is pressed, the callback receives the new value, and cleanup leaves the host empty. Those checks cover the component’s contract more directly than testing its exact internal markup.

A native button gives this counter keyboard activation and an accessible role without extra event handling. Its visible text also supplies a name, although a richer counter might use a separate label and value so its purpose remains clear as the number changes. For more complex components, decide how focus moves, whether status changes need to be announced, and which native elements already provide the desired behavior.

Clear boundaries do not require a framework or a large abstraction. They require agreement about who supplies input, who owns each element and listener, and how the component communicates and ends its work. Make those decisions first; the implementation can stay small.

Back to category ↗