Skip to content

Commit

Permalink
finish up to exercise 5
Browse files Browse the repository at this point in the history
  • Loading branch information
kentcdodds committed Mar 6, 2024
1 parent d6068e7 commit a77b46b
Show file tree
Hide file tree
Showing 20 changed files with 514 additions and 67 deletions.
20 changes: 8 additions & 12 deletions exercises/04.dom/01.problem.ref/README.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Refs

In this step we're going to use a completely different example. We're going to
make a `<Tilt />` component that renders a div and uses the `vanilla-tilt`
library to make it super fancy.
👨‍💼 Our users want a button they can click to increment a count a bunch of times.
They also like fancy things. So we're going to package it in a fancy way.

In this exercise we're going to use a completely different example. We're going
to make a `<Tilt />` component that renders a div and uses the
[`vanilla-tilt` library](https://micku7zu.github.io/vanilla-tilt.js/) to make it
super fancy.

The thing is, `vanilla-tilt` works directly with DOM nodes to setup event
handlers and stuff, so we need access to the DOM node. But because we're not the
Expand All @@ -16,12 +20,4 @@ Additionally, we'll need to clean up after ourselves if this component is
unmounted. Otherwise we'll have event handlers dangling around on DOM nodes that
are no longer in the document which can cause a memory leak.

To be clear about the memory leak, just imagine what would happen if we mount a
tilt element, then unmount it (without cleaning up). Those DOM nodes hang around
because the event listeners are still listening to events on them (even though
they're no longer in the document). Then let's say we mount a new one and
unmount that again. Now we have two sets of DOM nodes that are still in memory,
but not being used. We keep doing this over and over again and eventually our
computer is just keeping track of all these DOM nodes we don't need it to.
That's what's called a memory leak. So it's really important we clean up after
ourselves to avoid the performance problems associated with memory leaks.
The emoji will guide you. Enjoy!
2 changes: 2 additions & 0 deletions exercises/04.dom/01.solution.ref/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# Refs

👨‍💼 Great job! Now our users have a fancy counter!
31 changes: 31 additions & 0 deletions exercises/04.dom/02.problem.deps/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
# Dependencies

👨‍💼 Our users wanted to be able to control `vanilla-tilt` a bit. Some of them
like the speed and glare to look different. So Kellie 🧝‍♂️ added a form that will
allow them to control those values. The trouble is when the users change the
values, the vanilla tilt element isn't updated with the new values.

🦉 React needs to know when it needs to run your effect callback function again.
We do this using the dependency array which is the second argument to
`useEffect`. Whenever values in that array changes, React will call the returned
cleanup function and then invoke the effect callback again.

So far we've provided an empty dependency array which effectively tells React
that the effect doesn't depend on any values from the component. This is why the
effect is only run once when the component mounts and when values change it's
not run again.

By default, if you don't provide a second argument, `useEffect` runs after every
render. While this is probably the right default for correctness, it's far from
optimal in most cases. If you're not careful, it's easy to end up with infinite
loops (imagine if you're calling `setState` in the effect which triggers another
render and so on).

👨‍💼 So what we need to do in this step is let React know that our effect callback
depends on the `vanillaTiltOptions` the user is providing. Let's do that by
passing the `vanillaTiltOptions` in the dependency array.

<callout-warning class="aside">
You'll notice an issue when you've finished this step. If you click the button
to increment the count, the tilt effect is reset! We'll fix this in the next
step.
</callout-warning>
39 changes: 19 additions & 20 deletions exercises/04.dom/02.problem.deps/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// 1️⃣ 🐨 before you do anything else, head down to the useEffect and fix the
// dependency array!
import { useEffect, useRef, useState } from 'react'
import * as ReactDOM from 'react-dom/client'
import VanillaTilt from 'vanilla-tilt'
Expand All @@ -10,8 +8,10 @@ interface HTMLVanillaTiltElement extends HTMLDivElement {

function Tilt({
children,
// 4️⃣ 🐨 get rid of this rest operator and destructure each prop instead
...options
max = 25,
speed = 400,
glare = true,
maxGlare = 0.5,
}: {
children: React.ReactNode
max?: number
Expand All @@ -21,28 +21,20 @@ function Tilt({
}) {
const tiltRef = useRef<HTMLVanillaTiltElement>(null)

const vanillaTiltOptions = {
max,
speed,
glare,
'max-glare': maxGlare,
}

useEffect(() => {
const { current: tiltNode } = tiltRef
if (tiltNode === null) return
const vanillaTiltOptions = {
// 5️⃣ 🐨 get rid of options here and simply pass each individual option
...options,
max: 25,
speed: 400,
glare: true,
'max-glare': 0.5,
}
VanillaTilt.init(tiltNode, vanillaTiltOptions)
return () => tiltNode.vanillaTilt.destroy()
// 2️⃣ 🐨 OH NO! NEVER DISABLE THIS LINT RULE!
// Add the options to fix the original bug
// eslint-disable-next-line react-hooks/exhaustive-deps
// 🐨 Add vanillaTiltOptions to fix the original bug
}, [])
// 3️⃣ 🦉 once you add options to the dependency array though, you'll notice
// another bug... Clicking on the button resets the tilt effect because the
// options object is new every render! 🤦‍♂️
// 6️⃣ 🐨 get rid of the options from the dependency array and add each
// individual option.

return (
<div ref={tiltRef} className="tilt-root">
Expand Down Expand Up @@ -112,3 +104,10 @@ function App() {
const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)

// 🤫 we'll fix this in the next step!
// (ALMOST) NEVER DISABLE THIS LINT RULE IN REAL LIFE!
/*
eslint
react-hooks/exhaustive-deps: "off",
*/
3 changes: 3 additions & 0 deletions exercises/04.dom/02.solution.deps/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Dependencies

👨‍💼 Great! Now our users can control the tilt effect and that makes them happy.
But they're annoyed about something...
22 changes: 15 additions & 7 deletions exercises/04.dom/02.solution.deps/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@ function Tilt({
}) {
const tiltRef = useRef<HTMLVanillaTiltElement>(null)

const vanillaTiltOptions = {
max,
speed,
glare,
'max-glare': maxGlare,
}

useEffect(() => {
const { current: tiltNode } = tiltRef
if (tiltNode === null) return
const vanillaTiltOptions = {
max,
speed,
glare,
'max-glare': maxGlare,
}
VanillaTilt.init(tiltNode, vanillaTiltOptions)
return () => tiltNode.vanillaTilt.destroy()
}, [glare, max, maxGlare, speed])
}, [vanillaTiltOptions])

return (
<div ref={tiltRef} className="tilt-root">
Expand Down Expand Up @@ -102,3 +103,10 @@ function App() {
const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)

// 🤫 we'll fix this in the next step!
// (ALMOST) NEVER DISABLE THIS LINT RULE IN REAL LIFE!
/*
eslint
react-hooks/exhaustive-deps: "off",
*/
30 changes: 30 additions & 0 deletions exercises/04.dom/03.problem.primitives/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Primitive Dependencies

👨‍💼 Our users are annoyed. Whenever they click the incrementing button in the
middle, the tilt effect is reset. You can reproduce this more easily by clicking
one of the corners of the button.

If you add a `console.log` to the `useEffect`, you'll notice that it runs even
when the button is clicked, even if the actual options are unchanged. The reason
is because the `options` object actually _did_ change! This is because the
`options` object is a new object every time the component renders. This is
because of the way we're using the `...` spread operator to collect the options
into a single (brand new) object. This means that the dependency array will
always be different and the effect will always run!

`useEffect` iterates through each of our dependencies and checks whether they
have changed and it uses `Object.is` to do so (this is effectively the same
as `===`). This means that even if two objects have the same properties, they
will not be considered equal if they are different objects.

```tsx
const options1 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 }
const options2 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 }
Object.is(options1, options2) // false!!
```

So the easiest way to fix this is by switching from using an object to using the
primitive values directly. This way, the dependency array will only change when
the actual values change.

So please update the `useEffect` to use the primitive values directly. Thanks!
39 changes: 39 additions & 0 deletions exercises/04.dom/03.problem.primitives/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
Taken from the vanilla-tilt.js demo site:
https://micku7zu.github.io/vanilla-tilt.js/index.html
*/
.tilt-root {
height: 150px;
background-color: red;
width: 200px;
background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
transform-style: preserve-3d;
will-change: transform;
transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
}
.tilt-child {
position: absolute;
width: 50%;
height: 50%;
top: 50%;
left: 50%;
transform: translateZ(30px) translateX(-50%) translateY(-50%);
box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
background-color: white;
}
.totally-centered {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

.count-button {
width: 100%;
height: 100%;
background: transparent;
border: none;
font-size: 3em;
}
114 changes: 114 additions & 0 deletions exercises/04.dom/03.problem.primitives/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from 'react'
import * as ReactDOM from 'react-dom/client'
import VanillaTilt from 'vanilla-tilt'

interface HTMLVanillaTiltElement extends HTMLDivElement {
vanillaTilt: VanillaTilt
}

function Tilt({
children,
max = 25,
speed = 400,
glare = true,
maxGlare = 0.5,
}: {
children: React.ReactNode
max?: number
speed?: number
glare?: boolean
maxGlare?: number
}) {
const tiltRef = useRef<HTMLVanillaTiltElement>(null)

// 🐨 move this into the useEffect directly
const vanillaTiltOptions = {
max,
speed,
glare,
'max-glare': maxGlare,
}

useEffect(() => {
const { current: tiltNode } = tiltRef
if (tiltNode === null) return
VanillaTilt.init(tiltNode, vanillaTiltOptions)
return () => tiltNode.vanillaTilt.destroy()
// 🐨 instead of passing the options object here, pass each primitive option
}, [vanillaTiltOptions])

return (
<div ref={tiltRef} className="tilt-root">
<div className="tilt-child">{children}</div>
</div>
)
}

function App() {
const [count, setCount] = useState(0)
const [options, setOptions] = useState({
max: 25,
speed: 400,
glare: true,
maxGlare: 0.5,
})
return (
<div className="app">
<form
onSubmit={e => e.preventDefault()}
onChange={event => {
const formData = new FormData(event.currentTarget)
setOptions({
max: formData.get('max') as any,
speed: formData.get('speed') as any,
glare: formData.get('glare') === 'on',
maxGlare: formData.get('maxGlare') as any,
})
}}
>
<div>
<label htmlFor="max">Max:</label>
<input id="max" name="max" type="number" defaultValue={25} />
</div>
<div>
<label htmlFor="speed">Speed:</label>
<input id="speed" name="speed" type="number" defaultValue={400} />
</div>
<div>
<label>
<input id="glare" name="glare" type="checkbox" defaultChecked />
Glare
</label>
</div>
<div>
<label htmlFor="maxGlare">Max Glare:</label>
<input
id="maxGlare"
name="maxGlare"
type="number"
defaultValue={0.5}
/>
</div>
</form>
<br />
<Tilt {...options}>
<div className="totally-centered">
<button className="count-button" onClick={() => setCount(c => c + 1)}>
{count}
</button>
</div>
</Tilt>
</div>
)
}

const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)

// 🤫 we'll fix this in the next step!
// (ALMOST) NEVER DISABLE THIS LINT RULE IN REAL LIFE!
/*
eslint
react-hooks/exhaustive-deps: "off",
*/
6 changes: 6 additions & 0 deletions exercises/04.dom/03.solution.primitives/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Primitive Dependencies

👨‍💼 This is probably one of the more annoying parts about dependency arrays.
Luckily modern React applications don't need to reach for `useEffect` for many
use cases (thanks to frameworks like Remix), but it's important for you to
understand.
Loading

0 comments on commit a77b46b

Please sign in to comment.