Components: The building blocks of UI
As JavaScript became more powerful, the browser transformed from a rendering engine into a sandbox. User expectations kept-up and users demanded more: suddenly, it wasn't an acceptable user experience to refresh the page when a user added something to their shopping cart, or their password and confirm password fields didn't match, web apps were expected to compute and rerender those details client-side.
The natural result was increasingly large parts of web pages had to be rendered on the client; however, there was no canonical way to do this. On the server, data was fetched in response to user requests and piped into templates, but the client lived in this awkward in-between where the initial render came from the server, but then subsequent renders and updates had to be constructed on the client.
The split source-of-truth between initial render and updates led to predictable problems: HTML fell out-of-sync with data in JavaScript, and rerenders could change the UI in unexpected, and often unintentional, ways.
JSX: HTML in JS
It is in this context that JSX was born. Technologically, JSX wasn't that interesting, but as a developer experience improvement, it was genius. JSX flipped the paradigm on its head: JavaScript, not HTML, became the source of truth for all renders (including the initial render). Because that required more HTML to be written in JavaScript than before, JSX introduced a first-class HTML-like syntax embedded within JavaScript: JSX.
JSX takes in a DOM element, and mounts a React tree into it using the .render()
method. If you're using a higher-level framework (such as Next.js), this is typically handled for you.
JSX enabled this syntax by adding a build step to transpile (opens in a new tab) the HTML-like syntax into JavaScript functions.
Not only could you render HTML inside of JavaScript, but you could also interpolate JavaScript back into HTML using curly braces.
Components
Beyond letting developers write the standard HTML tags, JSX allowed developers to define their own tags, called components, using JavaScript functions (tags native to HTML are called "primitives").
Like normal functions, these tags evaluate to their return value and take in arguments (called props and passed as attributes to the tag).
In fact, the App
function itself is a component!
One special prop to call out is children
; unlike other props, children
is defined as the contents of the tag (instead of as an attribute) and is always a ReactNode
(an object that react knows how to render into HTML).
// With this component definition
function CustomTag(props) {
return <div>
Hello, {props.children}!
</div>;
}
// This:
<CustomTag>
<span>world</span>
</CustomTag>
// Evaluates to this:
<div>
Hello, <span>world</span>!
</div>
Fragments
Something that's not quite a component, but looks like one is a React Fragment: <Fragment>
or <>
.
Fragments allow you to wrap multiple components as one, but don't render to any HTML themselves.
// With this component definition
function CustomTag(props) {
return <>
Hello, {props.children}!
</>;
}
// This:
<p>
<CustomTag>
<span>world</span>
</CustomTag>
</p>
// Evaluates to this:
<p>
Hello, <span>world</span>!
</p>
Reactivity
Every app needs to do two things: (1) the initial render; (2) updating the UI in response to user interaction.
Because React is a declarative framework, developers don't directly manipulate the DOM to update the UI; instead, developers describe the UI as a function of some data (called state), and update that state. When state is updated, React rerenders and computes any changes it needs to make to the DOM.
Event handlers
Typically user interactions cause state to change. To listen to user interactions, event handlers can be passed like any other attribute to HTML primitives.
State
State is created with useState()
, which takes an initial value generator and returns a getter and setter tuple:
const [getter, setter] = useState(() => {
// ... does some expensive compute and returns initial state...
return 0;
});
State is updated by calling the setter with a function that takes in the old state and returns the new state.
<div onClick={() => { setter((oldValue) => oldValue + 1) }} />
On click, the function passed to the setter is called, a new state is computed, and React triggers a rerender.
Two common shorthands for the above are:
useState(0)
is equivalent touseState(() => 0)
: This is dangerous if the initial value is expensive to compute or shouldn't be recreated; for example,useState(new ComplexClass())
will create a new instance ofComplexClass
every time the component rerenders.setState(1)
is equivalent tosetState(() => 1)
: This is dangerous if the new value depends on the old value (setCount(count + 1)
), as this code is evaluated at render time tosetCount(2)
, and it will set the value to2
regardless of how many times the user presses the button (until the frontend rerenders).
Because setState will trigger a rerender, you should avoid unconditionally calling setState during a render (as that will create an infinite render loop).
Rerendering
When state is updated in a component, React marks that component, and all of its descendent components, as "dirty"; whenever React has dirty nodes in its tree, it rerenders that subtree (React may rerender multiple subtrees simultaneously if they are all dirty and share no common ancestor that also needs rerendering).
To rerender a component, React:
- Renders (opens in a new tab): Calls the same function that initially rendered it
- Reconciles (opens in a new tab): Compares its output against its previous output to create a list of changes
- Commits (opens in a new tab): Applies those changes to the DOM
The second step was the technical innovation behind React. Reading and writing to the DOM is expensive, but by keeping an in-memory representation of the DOM (a "virtual DOM" (opens in a new tab)), developers only write one render function (as opposed to render and rerender) and React can quickly translate that into a list of updates.
As React rerenders the tree, it needs to determine whether the node it's currently rendering is the same as a previous node it has rendered, so it can reuse the state. React determines this by looking at:
- Is the parent node the same (this algorithm is recursive)
- Is this component defined by the same function ("same" meaning pointer equality)
- Is the key the same (more on this later)
The second rule is why components should never be defined inside of another function. For example, the counter below will reset state on every render.
function App() {
function Counter() {
const [count, setCount] = useState(() => 0);
return <div onClick={() => { setCount((oldCount) => oldCount + 1); }}>
Click me (current count: {count})
</div>;
}
return <Counter />;
}
Lists and keys
Reconciling state across elements of a list is tricky because every element has the same parent node and the same function. In the below exmaple, type something into one of the textboxes and click "add item".
The state will move components. This is because, by default, React will use the item's index to resolve any ambiguity in which child a state belongs to. You can override this by providing a key
attribute (opens in a new tab), keys tell React how to match children elements across rerenders. This line would fix the example above.
{items.map((item) => {
return <Item id={item} key={item} />;
})}
While primarily used for lists, keys are a general concept in react, and changing a key will reset the state.
Example: inputs
One place where this all comes together beautifully is when dealing with input tags.
In a controlled input (opens in a new tab) (where the value is defined in React),
- React renders an input tag that says "hello, w"
- The user types "o" into that input tag
- The event handler triggers, and reads whats in the input ("hello, wo")
- React updates the state to match that value
- React rerenders (because state was updated)
- React reconciles the differences to determine it should issue an update to the input from "hello, w" (what was in the virtual dom) to "hello, wo" (what was most recently rendered)
- The DOM update no-ops (because "hello, wo" is already the value)
Hooks
useState
is actually one of many built-in React "hooks" (opens in a new tab), or integration points with React. Hooks provide access to information stored across renders or hook into React's lifecycle.
You may have wondered how useState worked across renders, React knows when a component is rendering, and tracks the order of hook calls. For example, when rerendering the below, React knows to return "John" for the first useState call and "Doe" for the second call:
function SomeComponent() {
const [firstName, setFirstName] = useState("John");
const [lastName, setLastName] = useState("Doe");
// ...
}
Hooks are always prefixed with use
, and--because of their implementation--have two important rules:
Rule 1: Even though components are JavaScript functions, don't call them directly (e.g. never call const element = SomeComponent()
) because React does set-up before it renders components to enable hooks.
Rule 2: Hooks must be called in the same exact order on every render. That means, hooks should not be called conditionally (in an if-statement) or a variable number of times (in a loop). For example, as far as React is concerned, the first name state and last name state are the same: the second call to useState.
For a full list of built-in hooks, see the documentation (opens in a new tab). In addition, you can define your own hooks, just be sure to prefix them with use
.
Lifecycle
While rendering and rerendering are intended to be implementation details of React, sometimes you need to take action throughout the component's lifecycle. useEffect
(opens in a new tab) is a hook that takes in a function to call whenever the component has been rendered; it returns a function to call either before the component is rendered again or before it is removed from the DOM.
A common use-case for this is to synchronize the component with external sources of data. For example, here is a custom hook that stores the window size in state (so your component can react to it):
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
height: window.innerHeight,
width: window.innerWidth,
});
useEffect(() => {
const windowSizeHandler = () => {
setWindowSize({
height: window.innerHeight,
width: window.innerWidth,
});
};
// Subscribe when component is created
window.addEventListener("resize", windowSizeHandler);
return () => {
// Unsubscribe when component is removed
window.removeEventListener("resize", windowSizeHandler);
};
}, []);
return windowSize;
};
Because you may not want to run code on every render, useEffect
takes in a second parameter, a list of values. If provided, useEffect
will only rerun if one of those values changes. In our example, because we are passing an empty list, useEffect
will only run when the component is first created.
Refs
In addition to wanting to run code on render, sometimes you need escape hatches from React's declarative nature and access the DOM elements that React creates. Refs are how you do that.
In React, for historical reasons, ref
refers to two related, but distinct, concepts
useRef(...) (opens in a new tab): is a hook that takes in an initial value and returns a JavaScript object of the form { current: INITIAL_VALUE }
. This is useful because you can set the current
property to anything as a way to persist that data across renders (however, unlike state, changing it won't trigger a rerender).
const useIsFirstRender = () => {
// We're using a ref instead of state, because we don't want checking
// whether we're on the first render to immediately trigger a rerender.
const firstRenderRef = useRef(true);
const isFirstRender = firstRenderRef.current;
firstRenderRef.current = false;
return isFirstRender;
};
ref={...} (opens in a new tab): is a property on every tag in React that takes in a ref object and provides an imperative handle to that DOM element. For example, the below creates a text element which auto-focuses when rendered:
Custom components can also have refs, you can either forward the ref to one of the returned DOM nodes using forwardRef (opens in a new tab), or define a custom value to use as the value for the ref using useImperativeHandle (opens in a new tab).
For more complicated use-cases, for example when you have a variable amount of elements you need refs to, you can instead pass a function to ref={...}
that takes in the current DOM node.
function CustomTag() {
// This will call focus on itself EVERY time it's rendered
// A more practical example would be to add the ref to an array
return <input type='text' ref={(element) => element.focus()} />;
}
Higher order components
In many ways, components are like functions, and like functions, you may find yourself writing the same logic over and over again as you write more of them. When working with functions, you can reduce this repetition with higher order functions, and the same concept exists with components: higher order components (opens in a new tab) (HOCs).
Higher order components are functions that return components. For example, imagine you wanted every page to log whenever it rendered. Because of that you found every page beginning with the same:
useEffect(() => {
console.log("NAME OF PAGE")
}, [])
Instead of copy and pasting that across components, you could create a higher order component:
function withLogging(pageName, Component) {
return (props) => {
useEffect(() => {
console.log(pageName)
}, [])
return <Component {...props} />
}
}
And then create your components like this:
const MyPage = withLogging("MY PAGE", MyPageComponent);
That said, these days I don't see higher order components as much because hooks make a lot of that same logic reuse easier. For example, another way to implement the above is as a custom hook:
function useLogRender(name) {
useEffect(() => {
console.log(name)
}, []);
}
If you do find yourself creating HOCs, make sure to create them in the global scope of your application (and not during render), because React uses the function pointer to determine component equality, and if you create a new component function every render, React will never reconcile state across them.
File structure (locality)
Like functions do in normal JS, components create a natural place to introduce abstraction to your code:
root.render(
<App>
<Header />
<MainContents>
<Sidebar />
<SignUpPage />
</MainContents>
</App>
);
This abstraction is typically reflected in the file structure:
project-root/
components/
Header/
Header.jsx
Sidebar/
Sidebar.jsx
MainContents/
MainContents.jsx
pages/
SignUpPage/
SignUpPage.jsx
Conventionally--and unlike other programming paradigms--in a component-based architecture, all parts of a component (code, style, tests) are co-located to optimize discoverability (a principle referred to as locality).
project-root/
components/
Header/
Header.jsx
Header.test.jsx
Header.css
Styling
Speaking of styling: in recent years, CSS has transformed in many of the same ways as JavaScript has:
- Better syntax: LESS (opens in a new tab) and SASS (opens in a new tab) have emerged as alternative syntaxes to CSS.
- Modularization: CSS modules (opens in a new tab) help developers isolate code.
- Better support for components: With CSS modules (opens in a new tab) and a bundler (like webpack), I can now author a React component that imports a style and references any defined classnames like variables.
import s from "./my-style-file.module.scss";
function SomeReactComponent() {
return <div className={s.someClassname}>Hello, world!</div>
}
The above is my recommended way to write CSS; however, I'm keeping my eye on the efforts to colocate CSS definitions in JS (much like JSX moved HTML into JS files). Two examples are StyledComponents (opens in a new tab) and StyleX (opens in a new tab). I believe this is the right long-term direction, but there are still unsolved challenges around:
- Psuedo-selectors (
:hover
) - Psuedo-elements (
::before
) - Keyframes (
@keyframes
) - Media queries (
@media
) - etc.
If you can't use CSS modules for whatever reason, BEM (opens in a new tab) provides a structure to map your CSS to components.