React introduced hooks one year ago, and they've been a game-changer for a lot of developers. There are tons of how-to introduction resources out there, but I want to talk about the fundamental mindset change when switching from React class components to function components + hooks.
With class components, we tie updates to specific lifecycle events.
In a function component, we instead use the useEffect hook to run code during the major lifecycle events.
With class components, we tie updates to specific lifecycle events.
class Chart extends Component { componentDidMount() { // when Chart mounts, do this } componentDidUpdate(prevProps) { if (prevProps.data == props.data) return // when data updates, do this } componentWillUnmount() { // before Chart unmounts, do this } render() { return ( <svg className="Chart" /> ) }}In a function component, we instead use the useEffect hook to run code during the major lifecycle events.
const Chart = ({ data }) => { useEffect(() => { // when Chart mounts, do this // when data updates, do this return () => { // when data updates, do this // before Chart unmounts, do this } }, [data]) return ( <svg className="Chart" /> )}Great! At this point, you might be thinking:
Got it, so useEffect() is just a new way to hook into lifecycle events!but that's the wrong way to look at it.
Hmm, let's look at a concrete example to see what the difference is. For example, what if we have a computationally expensive getDataWithinRange() function that returns a filtered dataset, based on a specified dateRange? Because getDataWithinRange() takes some time to run, we'll want to store it in our component's state object and only update it when dateRange changes.
With lifecycle events, we need to deal with all changes in one spot. Our thinking looks something like:
When our component loads, and when props change (specificallydateRange), updatedata
In a function component, we need to think about what values stay in-sync. Each update flows more like the statement:
Keepdatain sync withdateRange
With lifecycle events, we need to deal with all changes in one spot. Our thinking looks something like:
When our component loads, and when props change (specificallydateRange), updatedata
class Chart extends Component { state = { data: null, } componentDidMount() { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) } componentDidUpdate(prevProps) { if (prevProps.dateRange != this.props.dateRange) { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) } } render() { return ( <svg className="Chart" /> ) }}In a function component, we need to think about what values stay in-sync. Each update flows more like the statement:
Keepdatain sync withdateRange
const Chart = ({ dateRange }) => { const [data, setData] = useState() useEffect(() => { const newData = getDataWithinRange(dateRange) setData(newData) }, [dateRange]) return ( <svg className="Chart" /> )}See how much easier it is to wrap your head around the concept of keeping variables in-sync?
In fact, this last example was still thinking inside the class-component box. We're storing data in state to prevent re-calculating it every time our component updates.
But we no longer need to use state! Here to the rescue is useMemo(), which will only re-calculate data when its dependency array changes.
class Chart extends Component { state = { data: null, } componentDidMount() { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) } componentDidUpdate(prevProps) { if (prevProps.dateRange != this.props.dateRange) { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) } } render() { return ( <svg className="Chart" /> ) }}In fact, this last example was still thinking inside the class-component box. We're storing data in state to prevent re-calculating it every time our component updates.
But we no longer need to use state! Here to the rescue is useMemo(), which will only re-calculate data when its dependency array changes.
const Chart = ({ dateRange }) => { const data = useMemo(() => ( getDataWithinRange(dateRange) ), [dateRange]) return ( <svg className="Chart" /> )}Not impressed yet? Fine.
Here is where this concept really shines.
Imagine that we have many values that we need to calculate, but they depend on different props. For example, we need to calculate:
data when our dateRange changes,dimensions of a chart when the margins change, andscales when our data changesIn our function component, we can focus on our simple statements, like:
Keepdimensionsin sync withmargins
Imagine that we have many values that we need to calculate, but they depend on different props. For example, we need to calculate:
data when our dateRange changes,dimensions of a chart when the margins change, andscales when our data changesclass Chart extends Component { state = { data: null, dimensions: null, xScale: null, yScale: null, } componentDidMount() { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) this.setState({dimensions: getDimensions()}) this.setState({xScale: getXScale()}) this.setState({yScale: getYScale()}) } componentDidUpdate(prevProps, prevState) { if (prevProps.dateRange != this.props.dateRange) { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) } if (prevProps.margins != this.props.margins) { this.setState({dimensions: getDimensions()}) } if (prevState.data != this.state.data) { this.setState({xScale: getXScale()}) this.setState({yScale: getYScale()}) } } render() { return ( <svg className="Chart" /> ) }}In our function component, we can focus on our simple statements, like:
Keepdimensionsin sync withmargins
const Chart = ({ dateRange, margins }) => { const data = useMemo(() => ( getDataWithinRange(dateRange) ), [dateRange]) const dimensions = useMemo(getDimensions, [margins]) const xScale = useMemo(getXScale, [data]) const yScale = useMemo(getYScale, [data]) return ( <svg className="Chart" /> )}See how unwieldy our class component got, even in our simple example?
This is because we had a lot of declarative code explaining how keep our variables in-sync with props and state, and in our function component, we get to focus only on what to keep in-sync.
Notice how we can use as many useMemo() hooks as we want - letting us keep the dependencies and effects as close as possible.
stateOkay! Moving on -- what if our scales needed to change based on the dimensions of our chart?
In our class component, we'll need to compare our prevState and current state.
Our hooks' dependency arrays don't care whether our margins or dimensions are in our props, state, or neither - a value is a value, as far as they're concerned.
In our class component, we'll need to compare our prevState and current state.
class Chart extends Component { state = { data: null, dimensions: null, xScale: null, yScale: null, } componentDidMount() { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) this.setState({dimensions: getDimensions()}) this.setState({xScale: getXScale()}) this.setState({yScale: getYScale()}) } componentDidUpdate(prevProps, prevState) { if (prevProps.dateRange != this.props.dateRange) { const newData = getDataWithinRange(this.props.dateRange) this.setState({data: newData}) } if (prevProps.margins != this.props.margins) { this.setState({dimensions: getDimensions()}) } if ( prevState.data != this.state.data || prevState.dimensions != this.state.dimensions ) { this.setState({xScale: getXScale()}) this.setState({yScale: getYScale()}) } } render() { return ( <svg className="Chart" /> ) }}Our hooks' dependency arrays don't care whether our margins or dimensions are in our props, state, or neither - a value is a value, as far as they're concerned.
const Chart = ({ dateRange, margins }) => { const data = useMemo(() => ( getDataWithinRange(dateRange) ), [dateRange]) const dimensions = useMemo(getDimensions, [margins]) const xScale = useMemo(getXScale, [data, dimensions]) const yScale = useMemo(getYScale, [data, dimensions]) return ( <svg className="Chart" /> )}Shorter code isn't necessarily easier to read, but keeping our components as clutter-free as possible is definitely a win!
Let's quickly compare the length of the examples we've just read:
Hey! Our function components are, on average, 46.1% of the length of their class component counterparts.
Additionally, let's highlight each of the code changes across the two versions:
We can see how we're keeping each concern separate from its friends - grouping by concern instead of by lifecycle event.
We've always had the ability to share utility functions between components, but they never had access to the components' lifecycle events. Sure, we had tricks like Higher-Order Components, but they often felt like more trouble than they were worth, and cluttered up our render functions.
In every project I'm currently working on, I have a library of simple hooks that just make my life easier. In my experience, the main benefits of being able to share complex code are:
dimensions in-sync with a wrapper element. This one especially was lots of code that was a pain to replicate for every chartOkay, Amelia, I get it. Let's get to the meat of the matter - here is a collection of hooks that I've been really appreciating these days.
export const useIsMounted = () => { const isMounted = useRef(false) useEffect(() => { isMounted.current = true return () => isMounted.current = false }, []) return isMounted}const useIsInView = (margin="0px") => { const [isIntersecting, setIntersecting] = useState(false) const ref = useRef() useEffect(() => { const observer = new IntersectionObserver(([ entry ]) => { setIntersecting(entry.isIntersecting) }, { rootMargin: margin }) if (ref.current) observer.observe(ref.current) return () => { observer.unobserve(ref.current) } }, []) return [ref, isIntersecting]}const useHash = (initialValue=null) => { const [storedValue, setStoredValue] = useState(() => { try { const item = window.location.hash return item ? item.slice(1) : initialValue } catch (error) { console.log(error) return initialValue } }) const setValue = value => { try { setStoredValue(value) history.pushState(null, null, `#${value}`) } catch (error) { console.log(error) } } return [storedValue, setValue]}const useOnKeyPress = (targetKey, onKeyDown, onKeyUp, isDebugging=false) => { const [isKeyDown, setIsKeyDown] = useState(false) const onKeyDownLocal = useCallback(e => { if (isDebugging) console.log("key down", e.key, e.key != targetKey ? "- isn't triggered" : "- is triggered") if (e.key != targetKey) return setIsKeyDown(true) if (typeof onKeyDown != "function") return onKeyDown(e) }) const onKeyUpLocal = useCallback(e => { if (isDebugging) console.log("key up", e.key, e.key != targetKey ? "- isn't triggered" : "- is triggered") if (e.key != targetKey) return setIsKeyDown(false) if (typeof onKeyUp != "function") return onKeyUp(e) }) useEffect(() => { addEventListener('keydown', onKeyDownLocal) addEventListener('keyup', onKeyUpLocal) return () => { removeEventListener('keydown', onKeyDownLocal) removeEventListener('keyup', onKeyUpLocal) } }, []) return isKeyDown}const combineChartDimensions = dimensions => { let parsedDimensions = { marginTop: 40, marginRight: 30, marginBottom: 40, marginLeft: 75, ...dimensions, } return { ...parsedDimensions, boundedHeight: Math.max(parsedDimensions.height - parsedDimensions.marginTop - parsedDimensions.marginBottom, 0), boundedWidth: Math.max(parsedDimensions.width - parsedDimensions.marginLeft - parsedDimensions.marginRight, 0), }}export const useChartDimensions = passedSettings => { const ref = useRef() const dimensions = combineChartDimensions(passedSettings) const [width, changeWidth] = useState(0) const [height, changeHeight] = useState(0) useEffect(() => { if (dimensions.width && dimensions.height) return const element = ref.current const resizeObserver = new ResizeObserver(entries => { if (!Array.isArray(entries)) return if (!entries.length) return const entry = entries[0] if (width != entry.contentRect.width) changeWidth(entry.contentRect.width) if (height != entry.contentRect.height) changeHeight(entry.contentRect.height) }) resizeObserver.observe(element) return () => resizeObserver.unobserve(element) }, []) const newSettings = combineChartDimensions({ ...dimensions, width: dimensions.width || width, height: dimensions.height || height, }) return [ref, newSettings]}set it -- but this should be updated in the future.