chan.dev / posts

Show More component

🌱 This post is in the growth phase. It may still be useful as it grows up.

This totorial is a complete guide to basic react hooks. It demonstratens simple implementations of useState, useEffect, useRef, useContext, and useId in a familiar, real-world component.

Contents

Online sandboxes

Make your own

Copy-paste this into your prefered React environment. Does not include bootstrapping with React.creatRoot.

/* START HERE */
function ShowMore(/* props */) {
return <div>🫵 YOUR IMPLEMENTATION 🫵</div>
}
export default function App() {
return (
<ShowMore>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Etiam eu purus turpis. Nulla efficitur pulvinar dui id
imperdiet. Nulla cursus nulla id elit imperdiet commodo.
Praesent ullamcorper eros quis maximus varius. Integer
pellentesque urna nulla, nec vestibulum leo malesuada
quis. Maecenas sit amet mauris eu diam blandit molestie
bibendum sit amet mauris. Nullam sed posuere lacus. Sed
cursus bibendum turpis tincidunt volutpat. Duis molestie
volutpat urna, in rutrum ante rhoncus volutpat. Maecenas a
imperdiet dolor. Duis ut ex tincidunt, tincidunt velit in,
vehicula dolor. Suspendisse dictum porttitor massa. Cras
pulvinar ultricies lacus ut maximus. In gravida turpis
purus, eu mattis odio tincidunt eget.
</ShowMore>
)
}

Create a component that renders children and a “Show less” button

Let’s start. Create a Component that renders children and a “Show less” button

(If you’re sure what this code does, visit my React Basics tutorial for a primer.)

// 1. Create a component that destructures `children` from props
function ShowMore({children}) {
return (
<div>
{/* 2. Render the `children` */}
<div>{children}</div>
{/* 3. Render a "Show less" button */}
<button>Show less</button>
</div>
)
}

Reference: React Basics, chan.dev.


Conditionally render toggle text using a ternary operator

The “Show more” button should read “Show less” when expanded. Add a condition around the button text so that it can be toggled.

(Don’t worry about state. Just activate it, manually, with true and false.)

function ShowMore({ children }) {
return (
<div>
<div>{children}</div>
{/* Conditionally render toggle text using a ternary operator. */}
<button>Show less{true ? "less" : "more"}</button>
</div>
);
}

Reference: Conditional (ternary) Operator, MDN.


Manage expanded state with state with the React.useState hook

The React.useState hook is how we manage UI state in React. Create a state, using a initial value. Then assign that state to a local variable by destructuring the returned array.

function ShowMore({ children }) {
// 1. Track initial state using and provide an initial value
// 2. Assign it to a local variable by destructuring the returned array
let [expanded] = React.useState(true);
return (
<div>
<div>{children}</div>
<button>Show {expanded ? "less" : "more"}</button>
</div>
);
}

Reference:


Add an onClick event handler to the button

UI changes are activated by user input. Add an onClick event handler to the button and log the expanded state.

function ShowMore({ children }) {
let [expanded] = React.useState(true);
return (
<div>
<div>{children}</div>
<button onClick={() => console.log(expanded)}>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Reference: Responding to Events, React Docs.


Call React.useState’s update function with new state

The second value that we get from React.useState is a state set function. Assign that function to a local variable by destructuring it from the returned array. Then call the set function in the button’s onClick event handler.

(To toggle the next state, invert the current state with ! operator.)

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
return (
<div>
<div>{children}</div>
<button onClick={() => console.log(expanded)}>
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Reference: useState set functions referenc, react.dev


Style the content container with a style prop

Styles rules can be applied directly to elements using the style prop. Style the collapsed state of the ShowMore component to be 100px tall and hide overflowing content.

(We want to animate this. So use maxHeight and set a transition.)

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
return (
<div>
<div
style={{
maxHeight: "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Reference: Applying CSS style, react.dev.


Conditionally style content container based on state

Style rules can be set conditionally using the ternary operator. Use a ternary to switch the maxHeight value from 100px to "none" when expanded.

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
return (
<div>
<div
style={{
maxHeight: expanded ? "none" : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Access content container’s scrollHeight with React.useRef

React.useRef is used to access the DOM node rendered from a component. Create a ref object. Then pass it to an React element via the ref (special) prop. Finally, log out the value in the onClick button handler to verify.

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
const contentRef = React.useRef(null);
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? "none" : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
<button
onClick={() => {
setExpanded(!expanded);
console.log(contentRef.current.scrollHeight);
}}
>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Reference: useRef hook reference, react.dev.


Set scrollHeight of DOM node on button click

Any state that we need to make rendering decisious should be tracked in state. Set the scrollHeight of our DOM node, on state, when our button is clicked.

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? "none" : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
<button
onClick={() => {
setExpanded(!expanded);
setContentHeight(contentRef.current.scrollHeight);
}}
>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Set maxHeight of content container with contentHeight state value

If we want to see our transition, we need to explicitly set the maxHeight of the content container (CSS stuff 😭). Use the contentHeight value (instead of "none") to set the expanded container maxHeight.

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
<button
onClick={() => {
setExpanded(!expanded);
setContentHeight(contentRef.current.scrollHeight);
}}
>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

Reference: Using CSS Transitions on Auto Dimensions, CSS Tricks.


Use React.useEffect to set contentHeight

Setting contentHeight on click is has a major flaw. The first click will not saw the transition animation. Move the setContentHeight function into a React.useEffect to set the contentHeight on every render.

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
React.useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight);
}
});
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
<button onClick={() => {
setExpanded(!expanded);
setContentHeight(contentRef.current.scrollHeight);
}>
Show {expanded ? "less" : "more"}
</button>
</div>
);
}

References: useEffect hook reference, react.dev.


Hide button if below height threshhold

This component has no purpose if content height is under our 100px threshold. Hide the the “Show more” button if height is less than 100px. Use the Logical AND operator (&&).

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
React.useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight);
}
});
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
{contentHeight > 100 && (
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
)}
</div>
);
}

Reference: Logical AND operator (&&), react.dev.


Update when window resizes, with useEffect

Use effect is design to sync events that external to React. Add a new useEffect that listens for window resize events and updates contentHeight.

function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
React.useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight);
}
});
React.useEffect(() => {
window.addEventListener("resize", () => {
setContentHeight(contentRef.current.scrollHeight);
});
});
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
{contentHeight > 100 && (
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
)}
</div>
);
}

Spot the memory leak with console.log

Not everything can be observed with console.log. But useEffect often can. Add a console.log statement to our resize useEffect to see how often it’s called.

React.useEffect(() => {
window.addEventListener("resize", () => {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
});
});

(It’s also easy to see this in the profiler tab of standard Chrome DevTools.)


Refactor the resize useEffect to call a function by reference

Writing functions inline is extremely convenient. But it means that we’re creating a new function every render. Separate the event handler declaration function and the addEventLister call.

function handleResize() {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
}
React.useEffect(() => {
window.addEventListener("resize", () => {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
});
window.addEventListener("resize", handleResize);
});

Add useEffect cleanup function

useEffect callback functions can return a cleanup function to unmount event listers and prevent memory leaks. Return a function from the resize useEffect to remove the event listener.

React.useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
});

Reference: Thinking from the Effect’s perspective, react.dev.


Debounce resize event handler

Debouncing is a technique to prevent a function from being called too frequently. Wrap the handleResize function in the debounce utility.

function handleResize() {
let handleResize = debounce(function () {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
}
});

This requires a debounce function.

I’d recommend using this one from lodash. In our case, you can use this one:

function debounce(func, timeout = 300) {
let timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, timeout)
}
}

Extract custom useEffect hook

We’re not limited to the hooks included in React. Extract the resize useEffect into a custom hook function. Take handleResize as an argument.

function useWindowResize(handleResize) {
React.useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
});
}
function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
React.useEffect(() => {
setContentHeight(contentRef.current.scrollHeight);
});
let handleResize = debounce(function () {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
});
React.useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
});
useWindowResize(handleResize);
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
{contentHeight > 100 && (
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
)}
</div>
);
}

Reference: Custom Hooks, react.dev.


Create Context with React.createContext

Context is a way to share state between components without passing props. Create a new context, to share expanded state with React.createContext.

let ExpandedContext = React.createContext();

Reference: createContext hook reference, react.dev.


Provide Context with the Context.Provider component

Context is provided to children with the Context.Provider component. Wrap the ShowMore render function in the ExpandedContext.Provider component and pass the expanded state to it’s value prop.

function ShowMore({ children }) {
/* ...code hidden for brevity... */
return (
<ExpandedContext.Provider value={expanded}>
<div>{/* ...code hidden for brevity... */}</div>
</ExpandedContext.Provider>
);
}

Reference: Context.Provider component reference, react.dev


Consume Context with useContext

Contexts are consumed in components with the useContext hook. Create a new component that consumes the ExpandedContext and renders the expanded state.

function ShowMoreButton() {
let expanded = React.useContext(ExpandedContext)
return <button>Show {expanded ? 'less' : 'more'}</button>
}

Reference: useContext hook reference, react.dev.


Provide value and set function on Context

For actions — like buttons — the components need access to state values and set functions. Modify the Context Provider and useContext consumer to use an array: [expanded, setExpanded].

const ExpandedContext = React.createContext();
function ShowMoreButton() {
let expanded = React.useContext(ExpandedContext);
let [expanded, setExpanded] = React.useContext(ExpandedContext);
return (
<button>
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
);
}
function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
React.useEffect(() => {
setContentHeight(contentRef.current.scrollHeight);
});
let handleResize = debounce(function () {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
});
React.useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
});
useWindowResize(handleResize);
return (
<ExpandedContext.Provider value={expanded}>
<ExpandedContext.Provider value={[expanded, setExpanded]}>
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
{contentHeight > 100 && <ShowMoreButton />}
</div>
</ExpandedContext.Provider>
);
}

Repeat for any required Contexts

const ExpandedContext = React.createContext();
const ContentHeightContext = React.createContext();
function ShowMoreButton() {
let [expanded, setExpanded] = React.useContext(ExpandedContext);
let [contentHeight, setContentHeight] =
React.useContext(ContentHeightContext);
return (
<>
{contentHeight > 100 && (
<button onClick={() => setExpanded(!expanded)}>
Show {expanded ? "less" : "more"}
</button>
)}
</>
);
}
function ShowMore({ children }) {
let [expanded, setExpanded] = React.useState(true);
let [contentHeight, setContentHeight] = React.useState();
const contentRef = React.useRef(null);
React.useEffect(() => {
setContentHeight(contentRef.current.scrollHeight);
});
let handleResize = debounce(function () {
setContentHeight(contentRef.current.scrollHeight);
console.log("resizing…");
});
React.useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
});
useWindowResize(handleResize);
return (
<ExpandedContext.Provider value={[expanded, setExpanded]}>
<ContentHeightContext.Provider value={[contentHeight, setContentHeight]}>
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? contentHeight : "100px",
overflow: "hidden",
transition: "all .5s ease",
}}
>
{children}
</div>
{/* {contentHeight > 100 && <ShowMoreButton />} */}
<ShowMoreButton />
</div>
</ContentHeightContext.Provider>
</ExpandedContext.Provider>
);
}

Bonus:

  • Put this entire package into a re-usable module and rename accordingly
  • Improve accessibility of component