Skip to content

Commit c3aae9c

Browse files
committed
deferred values
1 parent a8ad9d4 commit c3aae9c

File tree

5 files changed

+310
-0
lines changed

5 files changed

+310
-0
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Let's say you are texting your friend. You open iMessage, WhatsApp, Signal, ICQ, AIM, MSN Messenger etc. What is the actual order of operations?
2+
3+
1. You type your message
4+
1. You click send
5+
1. The message shows up in the log of messages _(this is the one we're interested in)_
6+
1. The message is actually sent over the network
7+
1. The recepient receives it
8+
9+
That #3 is interesting - you get visual feedback in your log of messages as if the message was already sent. In your mind, the message is sent, and the UI reflects that. In reality the message hasn't even left the device when it's first rendered that way, so in some ways it's a bit misleading, but this **optimistic** display of UI is more closely reflects the user's mental model.
10+
11+
That's what we're talking about, optimsitic UI updates - we're doing some backend work behind the scenes, but we're going to optimistically show the user that their update was done. This is possible to do without the ready-made hook `useOptimisticValue` but it was such a pain before. Now it's fairly easy to use in conjunction with useTransition (necessary to identify a low priority re-render) to show an intermediary state.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
Let's open the project.
2+
3+
1. Open optimisitic folder in your editor of choice
4+
1. Run `npm install`
5+
1. Glance at server.js - it responds to GET and POST on /thoughts
6+
1. Run `npm run dev:server` to run the server
7+
1. Run `npm run dev:client` in another terminal window to run the Vite server
8+
9+
You'll notice two things here in the server.js file, the DELAY and the ERROR_RATE. I intentionally slowed down the POST request so you can see how the loading state works. Feel free to change how long the delay is (it's in milliseconds). I also wanted you to see what errors look like, so it fails 20% of the time, this is also configurable.
10+
11+
Okay, let's build a UI that shows users deep thoughts, and allow them to post their own. In App.jsx, put
12+
13+
```javascript
14+
import { useEffect, useState } from "react";
15+
16+
export default function App() {
17+
const [thoughts, setThoughts] = useState([]);
18+
const [thought, setThought] = useState("");
19+
20+
async function postDeepThought() {
21+
setThought("");
22+
const response = await fetch("/thoughts", {
23+
method: "POST",
24+
headers: {
25+
"Content-Type": "application/json",
26+
},
27+
body: JSON.stringify({ thought }),
28+
});
29+
if (!response.ok) {
30+
alert("This thought was not deep enough. Please try again.");
31+
return;
32+
}
33+
const { thoughts: newThoughts } = await response.json();
34+
setThoughts(newThoughts);
35+
}
36+
37+
useEffect(() => {
38+
fetch("/thoughts")
39+
.then((res) => res.json())
40+
.then((data) => {
41+
setThoughts(data);
42+
});
43+
}, []);
44+
45+
return (
46+
<div className="app">
47+
<h1>Deep Thoughts</h1>
48+
<form
49+
onSubmit={(e) => {
50+
e.preventDefault();
51+
postDeepThought();
52+
}}
53+
>
54+
<label htmlFor="thought">What's on your mind?</label>
55+
<textarea
56+
id="thought"
57+
name="thought"
58+
rows="5"
59+
cols="33"
60+
value={thought}
61+
onChange={(e) => setThought(e.target.value)}
62+
/>
63+
<button type="submit">Submit</button>
64+
</form>
65+
<ul>
66+
{thoughts.map((thought, index) => (
67+
<li key={thought}>{thought}</li>
68+
))}
69+
</ul>
70+
</div>
71+
);
72+
}
73+
```
74+
75+
- Nothing surprising here. We get thoughts on load, and we reload them on POST
76+
- The big issue here is that the user is shown no feedback while the POST happening.
77+
- We could just append to thoughts and hope for the best - and this is what we used to do.
78+
- However we can use `useOptimisticState` and get a lot of the error cases and other problems wrapped up and taken care of for us.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
What happens if you have expensive (i.e. computationally difficult) components that need to re-render frequently? If you don't do something about it, it'll cause the infamous bad actor of frontend UIs: the dreaded **JANK** 👻
2+
3+
So how can we handle this? You have a UI that's getting all sorts of new input and we need to re-render at _some_ interval, but it'd be great if we could slow it down a bit so we don't get a janky UI - we just slow down the parts we need so that we don't cause the rest of the UI to hang.
4+
5+
So let's fathom our project : you and I are building an image editor that happens all client-side. We are tasked with building the filter side of it, so users can adjust brightness, saturation, sepia-tone, contract, and blur.
6+
7+
> We're going to have to simulate jank as we're going to use CSS instead of actual image processing, and the browser is too good at applying CSS 😅. But in reality if we were trying to actual image processing in the browser, jank would be a real concern.
8+
9+
You'll see a place where jank is frequently introduced - where we're talking user input and applying the values to UI elements. The browser measures so frequently that it becomes an issue.
10+
11+
The other thing we want: if you have a fast device, we want it to render more frequently, and if you have an old device (or low battery!) want it to render way less frequently. This is _very_ hard to do without hooking into React itself to accomplish.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
Open the `deferred` project from our repo.
2+
3+
Let's hop into our src directory and create a Slider.jsx file.
4+
5+
```javascript
6+
export default function Slider({ value, deferred, onChange, name, max }) {
7+
return (
8+
<li className="slider">
9+
<label htmlFor={name}>
10+
{name}
11+
{value !== deferred ? " (Updating)" : ""}
12+
</label>
13+
<input
14+
type="range"
15+
id={name}
16+
name={name}
17+
min="0"
18+
max={max}
19+
value={value}
20+
onChange={onChange}
21+
/>
22+
<output htmlFor={name}>
23+
Actual Value: {value} | Deferred Value: {deferred}
24+
</output>
25+
</li>
26+
);
27+
}
28+
```
29+
30+
- This is what we're going to use gather user input for what values they want applied to their image.
31+
- This is how you tell that something is in a inbetween state - deferred and value will be different. You'll see in just a sec.
32+
33+
Let's go create the image it will be applied to: DisplayImage.jsx
34+
35+
```javascript
36+
import img from "../images/luna.jpg";
37+
38+
const JANK_DELAY = 100;
39+
40+
export default function DisplayImage({ filterStyle }) {
41+
const expensiveRender = () => {
42+
const start = performance.now();
43+
while (performance.now() - start < JANK_DELAY) {}
44+
return null;
45+
};
46+
47+
return (
48+
<>
49+
{expensiveRender()}
50+
<img src={img} alt="Luna" style={{ filter: filterStyle }} />
51+
<p>Last render: {Date.now()}</p>
52+
</>
53+
);
54+
}
55+
```
56+
57+
- Beyond that it's just going to render of image (in my case it's my lovely asshole dog Luna, feel free to use your own!!)
58+
- Why the date? I want you to be able to see how frequently that image gets re-rendered.
59+
- expensiveRender is masquerading as a component with its return null.
60+
- What it's really doing is just slowing itself down 100ms intentionally so it can simulate jank well.
61+
- Feel free to modify `JANK_DELAY` to simulate speeding up and slowing down your rendering. 100ms was what I needed to notice big jank on my Macbook Air. Yours may be different.
62+
63+
Okay, now head to App.jsx
64+
65+
```javascript
66+
import { useState } from "react";
67+
import Slider from "./Slider";
68+
import DisplayImage from "./DisplayImage";
69+
70+
export default function App() {
71+
const [blur, setBlur] = useState(0);
72+
const [brightness, setBrightness] = useState(100);
73+
const [contrast, setContrast] = useState(100);
74+
const [saturate, setSaturate] = useState(100);
75+
const [sepia, setSepia] = useState(0);
76+
77+
const filterStyle = `
78+
blur(${blur}px)
79+
brightness(${brightness}%)
80+
contrast(${contrast}%)
81+
saturate(${saturate}%)
82+
sepia(${sepia}%)
83+
`;
84+
85+
return (
86+
<div className="app">
87+
<h1>Deferred Value</h1>
88+
<DisplayImage filterStyle={filterStyle} />
89+
<ul>
90+
<Slider
91+
value={blur}
92+
deferred={blur}
93+
onChange={(e) => setBlur(e.target.value)}
94+
name="Blur"
95+
max="20"
96+
/>
97+
<Slider
98+
value={brightness}
99+
deferred={brightness}
100+
onChange={(e) => setBrightness(e.target.value)}
101+
name="Brightness"
102+
max="200"
103+
/>
104+
<Slider
105+
value={contrast}
106+
deferred={contrast}
107+
onChange={(e) => setContrast(e.target.value)}
108+
name="Contrast"
109+
max="200"
110+
/>
111+
<Slider
112+
value={saturate}
113+
deferred={saturate}
114+
onChange={(e) => setSaturate(e.target.value)}
115+
name="Saturate"
116+
max="200"
117+
/>
118+
<Slider
119+
value={sepia}
120+
deferred={sepia}
121+
onChange={(e) => setSepia(e.target.value)}
122+
name="Sepia"
123+
max="100"
124+
/>
125+
</ul>
126+
</div>
127+
);
128+
}
129+
```
130+
131+
- Now we can see something rendered. Notice it's _super_ janky.
132+
- You'll notice that DisplayImage is re-rendering 100% of the time. This is because we haven't memoized it and also filterStyle is changing every time a slider is. When props change, a component will always re-render, memoized or not.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
So how can we make things less janky?
2+
3+
In the past we have to use something like [throttle or debounce][throttle] - we'd limit how frequently we'd allow the functions to update. This works but it's a blunt instrument - it's complicated code (I have to look up debounce vs throttle every time) and it doesn't scale with the user's device. Slow and fast devices alike will get slowed down.
4+
5+
Enter `useDeferredValue`. It uses React's low priority rendering to say "hey, if you're still busy doing high priority stuff, feel free to delay changing this low priority stuff" to React. What's cool about that is this _will_ scale with the user's device. A fast device will chew through updates quickly and get to the low priority updates quickly while a slower device will be given space to sort through high priority updates and wait for the lower ones.
6+
7+
Let's make it happen.
8+
9+
```javascript
10+
// replace filterStyles
11+
const deferredBlur = useDeferredValue(blur);
12+
const deferredBrightness = useDeferredValue(brightness);
13+
const deferredContrast = useDeferredValue(contrast);
14+
const deferredSaturate = useDeferredValue(saturate);
15+
const deferredSepia = useDeferredValue(sepia);
16+
17+
const filterStyle = `
18+
blur(${deferredBlur}px)
19+
brightness(${deferredBrightness}%)
20+
contrast(${deferredContrast}%)
21+
saturate(${deferredSaturate}%)
22+
sepia(${deferredSepia}%)`;
23+
24+
25+
// change deferred
26+
<Slider
27+
value={blur}
28+
deferred={deferredBlur}
29+
onChange={(e) => setBlur(e.target.value)}
30+
name="Blur"
31+
/>
32+
<Slider
33+
value={brightness}
34+
deferred={deferredBrightness}
35+
onChange={(e) => setBrightness(e.target.value)}
36+
name="Brightness"
37+
/>
38+
<Slider
39+
value={contrast}
40+
deferred={deferredContrast}
41+
onChange={(e) => setContrast(e.target.value)}
42+
name="Contrast"
43+
/>
44+
<Slider
45+
value={saturate}
46+
deferred={deferredSaturate}
47+
onChange={(e) => setSaturate(e.target.value)}
48+
name="Saturate"
49+
/>
50+
<Slider
51+
value={sepia}
52+
deferred={deferredSepia}
53+
onChange={(e) => setSepia(e.target.value)}
54+
name="Sepia"
55+
/>
56+
```
57+
58+
- We still have jank - that's expected, we have a 200ms delay chucked into our code. But it's working _much_ better than it was.
59+
- There's probably a clever way to just useDeferredValue with just the filterStyle or other things - notice it's deferred **value** and not deferred **state** - you can use it with any value.
60+
61+
Let's take it one step further. As long as a deferred value hasn't changed, that means the filterStyle string hasn't changed. Using `memo` like we learned before allows us to prevent the notorious jank-inducer `expensiveRender` from running. Let's go do that.
62+
63+
In DisplayImage
64+
65+
```javascript
66+
import { memo } from "react";
67+
68+
export default memo(function DisplayImage({ filterStyle }) {
69+
// other code here
70+
});
71+
```
72+
73+
- So much less jank
74+
- Now watch that `Last render` value - notice it doesn't change until the deferred value catches up
75+
76+
That's it! Using our new tools we can even work around heavy computation by using a little help from the React scheduler!
77+
78+
[throttle]: https://css-tricks.com/debouncing-throttling-explained-examples/

0 commit comments

Comments
 (0)