Overview
benefits
- break down the UI codebase into individual, manageable files.
- describe UI with HTML-like markup within TypeScript files (JSX/TSX).
- import and consume third-party components.
- entrust React to manage the DOM and keep it synced with data and state.
JSX
overview
JSX is a variant of JS that allows inline HTML-like snippets:
const hiText = <div>Hi</div>
We can interweave HTML and JS:
const hiText = <div>Hi {nickname}</div>
start an inline HTML-like snippet
HTML-like syntax starts at the first HTML element and ends on the matching closing one.
const hiText = <div>Hi</div>
embed JS
Within the snippet, we embed some JS within curly braces.
const hiText = <div>Hi {nickname}</div>
const hiText = <div style={styleObject}>Hi</div>
transpile
The transpiler transforms HTML-like snippets into JS code: HTML elements are transformed to function calls:
const hi = <div>Hi</div>
const hi = _jsx("div", { children: "Hi" })
.tsx
TSX is the equivalent of JSX for TypeScript. Files have the tsx extension.
React components
A React component describes a piece of UI, and defines it through a tree of markup.
- It can receive data from its parent and make use of it (props)
- It can declare its own state, scoped to itself. Scoped state improves encapsulation.
- It can import and use other components.
// describe a piece of UI
function Home() {
return <div>Hi</div>
}
// encapsulate some state
function Counter() {
// state
const [count, setCount] = React.useState<number>(0)
function onClick() {
setCount(count + 1)
}
// UI
return (
<div>
<button onClick={onClick}>add 1</button>
<div>{count}</div>
</div>
)
}
// use other components
import { Header, Footer } from "./components"
function Home() {
return (
<>
<Header />
// ...
<Footer />
</>
)
}
Props
React components, as functions, work with a single parameter, a plain object, that we call props by convention.
function Home(props: T) {}
The props object contains the props passed by the parent. Its type reflects what is expected from the parent:
type T = { x: number; y: number }
//
function Home(props: T) {
// const {x, y} = props
}
At call site, we pass the expected props:
<Home x={3} y={6} />
Note: we frequently inline destructure props:
function Home({ x, y }: T) {
//
}
parameter-less components
parameter-less components produce the same markup unconditionally.
function Home() {
//
}
children
We can nest elements under the component:
<Container>
<p>Hello</p>
</Container>
React passes such elements to the component as a prop, the children prop, whose type is ReactNode.
If the component is to use it, it declares a children prop:
function Container(props: { children: ReactNode }) {
return <div>{props.children}</div>
}
Note: we can technically nest elements by passing them to the children prop, but it is much less readable:
<Container children={<p>Hello</p>} />
conceptual
props as dependencies
Props are dependencies. When the parent calls the component with different props, its content changes.
conceptual: a contract
We set up a contract between the component and its caller: The component declares a list of props it expects to receive. It is then up to the caller to provide them.
useState
We request React to:
- create and store a state variable
- give us a view on it (immutable view)
- give us a way to request change, though a dispatch function. It is up to React to acknowledge the request and update the document when it deems appropriate.
initial value and type
useState<number>(0)
receive the view and a dispatch function
const [count, setCount] = React.useState<number>(0)
call the dispatch function with a raw value
In its simplest form, the dispatch function expects a raw value:
setCount(1)
Note: if we use state we have in scope, it still resolves to a raw value. When calling it multiple times, we set the state to the same value (no effect)
setCount(count + 1)
// resolves to:
setCount(1)
queue state mutation
If we want to derive a value from the current state, we either:
- refer to the state value managed by React. We pass a callback, to which React passes the state value as it keeps track of.
- This pattern supports queuing state changes that resolve on state update. React correctly accounts for the queued mutations:
// note: count will increase by 2
setCount((count) => count + 1)
setCount((count) => count + 1)
useRef
controlled vs uncontrolled element
In principle, we follow the pattern where React manages the state and update the DOM elements on its own. This pattern works with controlled elements, that is, elements on which we plug the value prop that React sees and registers. The pattern is to plug state to the value prop and to request state change in the onChange prop.
Otherwise, DOM elements evolve independently (uncontrolled element): their value doesn't derive from the state, and evolve outside of the React realm, and they do not require nor trigger React re-renders on change. We can set the initial value with defaultValue.
In scenarios where we want to imperatively read or manage an uncontrolled element, we must get a stable reference to it, so that we can read or set its value at arbitrary times using regular DOM APIs. The combination of useRef and ref allows requesting a reference to such an uncontrolled element.
Such reference persists across renders and is only discarded when the component unmounts, which discards the DOM element.
get a reference to an uncontrolled DOM element
In this example, we request a reference to the <input> DOM element, so that keystrokes do not trigger a React re-render.
const myInput = useRef<HTMLInputElement>(null)
// ..
<input ref={myInput}/>
We indicate the element's DOM type as a type parameter. The type can be as broad as HTMLElement, but in this example it is narrowed down to the more accurate HTMLInputElement.
We don't need to indicate null as a type parameter. React already adds it on our behalf.
use the reference
the current property starts as null because there is no way to get a valid DOM reference before the element is mounted (the component has not be painted, the DOM node doesn't exist yet). Later, React assigns the valid DOM reference to current.
myInput.current // the DOM element
// use DOM APIs (not React specific)
if (!myInput.current) return
myInput.current.innerText
myInput.current.innerHTML
myInput.current.value
other uses
We can use useRef to hold any kind of value that persists across renders, and that is only discarded on unmount. For example, we store a counter that increments when the component renders. Because it is not part of the React's state, its incrementation does not trigger a re-render.
useEffect
An effect is a function tied to a specific component which runs on specific events or conditions.
non-blocking
The effect never delays the component being painted on-screen: neither the first paint nor the subsequent ones. Instead, effects run after the first paint and, if eligible, run after subsequent re-renders. This pattern aims to prioritize time-to-paint and time-to-render.
If the effect fetches data to be displayed on-screen, we have to deal with a first paint which doesn't have such data. We either use a default value, or hide the UI altogether and display a loader, a skeleton or nothing.
one or multiple runs
An effect runs at least once, after the first paint. The subsequent runs are conditionals. We can ask for the effect to run:
- after every render
- after renders where one or several variables changed in value.
dependencies
useEffect(effect, []) // once
useEffect(effect, [x]) // once, then when x changes
useEffect(effect) // on every render
clean-up on unmount
The component can unmount at any time. We perform clean-up in certain conditions:
- we have set-up a subscription and we want to cancel it.
- we have initiated a network fetch, and we want to disable the callback. We do that by setting a flag such as
isStaleto false, and by ensuring the callback doesn't mutate state when such flag is false.
clean-up on dependency change
The dependency change also triggers the clean-up function. That is why network fetches should also cancel when the effect changes in nature because of the dependency change, so we can use the term isStale instead of isMounted.
synopsis
useEffect(f, [])
synchronous function
Even though the effect can start asynchronous tasks, it must itself come as a non-async function, that is, it must run and return immediately. As such, we may not use await directly in its body, but we may define an async function which uses await in its body and call that function instead.
function myEffect() {
/* effect content */
return /* clean up content */
}
Context and Providers
A provider exposes a value to its descendant components, without the need to pass props:
<FooContext.Provider value={3}>
<X>
<X1 />
</X>
<Y />
<Z />
</FooContext.Provider>
Descendants read the value by referring to the context (through the use of useContext):
// X1.tsx
const value = useContext(FooContext) // 3
We define the context object beforehand. Note: the initial value is overridden by the provider.
const FooContext = createContext<number>(0)
optional pattern: helper hook
We make a helper function that reads the appropriate context. The consumer doesn't have to import the Context object anymore:
// FooContext.tsx
export function useFoo() {
return useContext(FooContext)
}
// X1.tsx
const value = useFoo()
optional pattern: custom provider
We make a custom component that initializes the .Provider component with the correct value, encapsulating away the management of the value prop. The component also hosts the state if applicable. It can then expose the state and the dispatch function, if applicable.
In this example, we expose the state and the dispatch function. We start the context with undefined. In the custom hook, we check against it.
Note: Instead of inlining value={{ foo, setFoo }} in the value prop, which is an object that is created on every renders, and thus signaling the value has changed, we instead memoize an object and use it instead. This is only useful when the value is an object that is created inside the component. It isn't needed when we use a stable reference to the state, such as when doing value={ foo }. It is also theoretically not needed when the provider itself doesn't re-render, which occurs when it's stable at the root of the tree. Having parent providers that change doesn't mean that the nested child provider re-render. We can still use it for extra-safety.
// FooContext.tsx
type T = {
foo: number
setFoo: Dispatch<SetStateAction<number>>
}
// 1. context
const FooContext = createContext<T | undefined>(undefined)
// 2. custom provider
function FooProvider({ children }: { children: ReactNode }) {
const [foo, setFoo] = useState<number>(0)
const value = useMemo(() => ({ foo, setFoo }), [foo])
return <FooContext.Provider value={value}>{children}</FooContext.Provider>
}
// 3. custom reader (helper hook)
export function useFoo() {
const value = useContext(FooContext)
if (!value) throw new Error("useFoo must be used within the Provider")
return value
}
We can then use the component instead of the .Provider. The consumer doesn't have to import the Context object anymore:
// App.tsx
<FooProvider>
<X />
<Y />
</FooProvider>
// X.tsx
const { foo, setFoo } = useFoo()
Global props
React changes the behavior of some global props:
the style prop
<div
style={{
border: "2px solid black",
borderRadius: "5px",
width: "100px",
height: "200px",
color: "black",
display: "inline-block",
backgroundColor: "beige",
}}
>
{/* ... */}
</div>