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:
Keepdata
in 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:
Keepdata
in 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:
Keepdimensions
in 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:
Keepdimensions
in 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.
state
Okay! 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.