mirror of
https://github.com/LukeHagar/unicorn-utterances.git
synced 2025-12-09 12:57:45 +00:00
docs: add more about theme providing
This commit is contained in:
@@ -10,14 +10,54 @@
|
||||
}
|
||||
---
|
||||
|
||||
> Some warning about how this is an experimental API and may change in the future
|
||||
> **This article is talking about an experimental React API:**
|
||||
> There may be unexpected bugs and issues with it.
|
||||
>
|
||||
> In addition, this API is not available in stable React, you need to use a [canary release](https://react.dev/community/versioning-policy#canary-channel) of React
|
||||
|
||||
1) Compare/contrast React useMemo/useCallback/memo/cache
|
||||
2) Show how `cache` persists between functions
|
||||
React is going through a growth cycle! Between fundamental shifts like [React Server Components](/posts/what-are-react-server-components) to newer APIs like [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue) and [`useTransition`](https://react.dev/reference/react/useTransition), there's never been a better time to learn a new React API.
|
||||
|
||||
Speaking of new React APIs, let's take a look at one that's been introduced the [React's canary channel](https://react.dev/community/versioning-policy#canary-channel) lately: `cache`.
|
||||
|
||||
# What is React's `cache` function?
|
||||
|
||||
At its core, React's `cache` function enables you to wrap a function to avoid recomputing results when passing the same arguments to them.
|
||||
|
||||
Take the following example:
|
||||
|
||||
```jsx
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { cache, useReducer, useState } from "react";
|
||||
const alertCounter = (id) => {
|
||||
alert(id);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [_, rerender] = useReducer(() => ({}), {});
|
||||
|
||||
alertCounter(counter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCounter((v) => v + 1)}>Add to {counter}</button>
|
||||
<!-- Force a re-render to see the alert -->
|
||||
<button onClick={rerender}>Rerender</button>
|
||||
<!-- To verify that we're actually re-rendering, any input value should disappear between renders -->
|
||||
<input key={Math.floor(Math.random() * 10)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<!-- TODO: Show broken example -->
|
||||
|
||||
Now traditional React rules would say that `alertCounter` should show an `alert` on every render, regardless of if `counter` is being changed or not. We can see this whenever we trigger a re-render manually without changing `counter`.
|
||||
|
||||
But what if we could leave `App` unchanged and only have `alertCounter` re-run whenever `counter` is updated in the component?
|
||||
|
||||
Well, with `cache`, we can;
|
||||
|
||||
```jsx {0,2-4,10}
|
||||
import { cache, useState, useReducer } from "react"
|
||||
|
||||
const test = cache((id) => {
|
||||
alert(id);
|
||||
@@ -26,50 +66,200 @@ const test = cache((id) => {
|
||||
function App() {
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [_, rerender] = useReducer(() => ({}), {});
|
||||
test(counter);
|
||||
|
||||
alertCounter(counter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCounter((v) => v + 1)}>Add to {counter}</button>
|
||||
<!-- Force a re-render to see the alert -->
|
||||
<button onClick={rerender}>Rerender</button>
|
||||
<!-- To verify that we're actually re-rendering, any input value should disappear between renders -->
|
||||
<input key={Math.floor(Math.random() * 10)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
```
|
||||
|
||||
Mention this as useful when combined with React's `use` Hook
|
||||
Now if we force a re-render without changing `count`, it will no longer `alert`:
|
||||
|
||||
It even caches results:
|
||||
<!-- TODO: Show working example -->
|
||||
|
||||
```jsx
|
||||
const getIsEvenOrOdd = cache(
|
||||
(number) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(number % 2 === 0 ? "Even" : "Odd");
|
||||
}, 2000);
|
||||
}),
|
||||
);
|
||||
This is because the `cache` function is [_memoizing_](https://whatthefuck.is/memoization) the usage of the function and eagerly opting out of execution as a result.
|
||||
|
||||
|
||||
|
||||
# How does `cache` differ from `useMemo` or `memo`?
|
||||
|
||||
The experienced React developers among us may point to two similar APIs that also memoize values in React:
|
||||
|
||||
1) [`memo`](https://react.dev/reference/react/memo)
|
||||
2) [`useMemo`](https://react.dev/reference/react/useMemo)
|
||||
|
||||
The first of these comparisons isn't quite apt; `memo` is used to avoid re-renders in a component by memoizing a function component's based on its props.
|
||||
|
||||
But the second API, `useMemo`, is an interesting comparison. After all, we could modify the above to do something similar for us:
|
||||
|
||||
```jsx {8}
|
||||
const alertCounter = (id) => {
|
||||
alert(id);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [_, rerender] = useReducer(() => ({}), {});
|
||||
|
||||
const isEvenOrOdd = getIsEvenOrOdd(counter);
|
||||
useMemo(() => alertCounter(counter), [counter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCounter((v) => v + 1)}>Add to {counter}</button>
|
||||
<p>
|
||||
{counter} is {isEvenOrOdd}
|
||||
</p>
|
||||
<!-- Force a re-render to see the alert -->
|
||||
<button onClick={rerender}>Rerender</button>
|
||||
<!-- To verify that we're actually re-rendering, any input value should disappear between renders -->
|
||||
<input key={Math.floor(Math.random() * 10)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
And errors:
|
||||
That said, `cache` has two primary benefits over `useMemo`:
|
||||
|
||||
1) You don't need to modify the component code itself to cache the function results
|
||||
2) `cache` caches results between components
|
||||
|
||||
I personally don't find the first argument particularly compelling, so let's take a look at the second reason; **cross-component result caching**.
|
||||
|
||||
## Cross-component result caching
|
||||
|
||||
Say that you're looking to generate a theme based on the users' input:
|
||||
|
||||

|
||||
|
||||
> I didn't spend long optimizing how the theme would look for different color types. Admittedly, this doesn't look amazing, but it will suffice for the sake of a demo.
|
||||
|
||||
Now imagine that you want your code generation to occur only once per user color selection; after all, generating a sufficiently complex color palette can be an expensive and synchronous task at times.
|
||||
|
||||
Using the `cache` function, we can do something like this:
|
||||
|
||||
```jsx
|
||||
const getTheme = cache((primaryColor) => {
|
||||
// Theoretically, this could get very expensive to compute
|
||||
// Depending on how many colors and how accurately
|
||||
const [secondaryColor, tertiaryColor] =
|
||||
generateComplimentaryColors(primaryColor);
|
||||
return {
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
tertiaryColor,
|
||||
primaryTextColor: getReadableColor(primaryColor),
|
||||
secondaryTextColor: getReadableColor(secondaryColor),
|
||||
tertiaryTextColor: getReadableColor(tertiaryColor),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
To then generate a theme based on the user selection:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const [themeColor, setThemeColor] = useState("#7e38ff");
|
||||
const [tempColor, setTempColor] = useState(themeColor);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>
|
||||
<div>Primary color</div>
|
||||
<input
|
||||
type="color"
|
||||
id="body"
|
||||
name="body"
|
||||
value={tempColor}
|
||||
onChange={(e) => setTempColor(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => setThemeColor(tempColor)}>Set theme</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
And finally, we can display this color palette in a table:
|
||||
|
||||
```jsx
|
||||
<table>
|
||||
<tbody>
|
||||
<ThemePreviewRow type="primary" themeColor={themeColor} />
|
||||
<ThemePreviewRow type="secondary" themeColor={themeColor} />
|
||||
<ThemePreviewRow type="tertiary" themeColor={themeColor} />
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
Where each of the `ThemePreviewRow` component instances is accessing the same `getTheme` memoized function:
|
||||
|
||||
```jsx {1-3}
|
||||
function ThemePreviewRow({ type, themeColor }) {
|
||||
// The calculations to get the theme only occur once, even though this is
|
||||
// called in multiple component instances.
|
||||
const theme = getTheme(themeColor);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<th>{capitalize(type)}</th>
|
||||
<td>
|
||||
<div
|
||||
className="colorBox"
|
||||
style={{
|
||||
backgroundColor: theme[type + "Color"],
|
||||
color: theme[type + "TextColor"],
|
||||
}}
|
||||
>
|
||||
Some Text
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This allows us to avoid passing down the entire theme for each `ThemePreviewRow` components, instead relying on `cache`'s memoization to allow multiple components to access the values each.
|
||||
|
||||
<!-- TODO: Add code embed -->
|
||||
|
||||
# Other notable things about `cache`
|
||||
|
||||
There's a few other things about `cache` that I'd like to talk about. Notably;
|
||||
|
||||
1) [`cache` can be used to pre-load data for a component](#cache-preload)
|
||||
2) [Errors thrown within a `cache` call are memoized](#errors-cache)
|
||||
## Use `cache` to pre-load data {#cache-preload}
|
||||
|
||||
Because `cache`'s returned results are cached based on the user's input, we're able to eagerly access data when we know it will be needed, but before its actually needed.
|
||||
|
||||
IE in our `App` from before:
|
||||
|
||||
```jsx {4}
|
||||
function App() {
|
||||
const [themeColor, setThemeColor] = useState("#7e38ff");
|
||||
const [tempColor, setTempColor] = useState(themeColor);
|
||||
|
||||
getTheme(themeColor);
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
By doing this, our objective is that our theme will have been generated by the time we get to render our first `ThemePreviewRow`.
|
||||
|
||||
While this advice is less/not useful for synchronous tasks, you're able to use `cache` to get async results as well, where this would be much more handy:
|
||||
|
||||
```jsx
|
||||
```
|
||||
|
||||
This is a pattern you'll often see with asynchronous server components.
|
||||
|
||||
## Errors are memoized in `cache` {#errors-cache}
|
||||
|
||||
```jsx
|
||||
const getIsEven = cache((number) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>React Reactivity</title>
|
||||
<title>React Basic Cache Usage</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@unicorn-utterances/react-reactivity",
|
||||
"name": "@unicorn-utterances/react-basic-cache-usage",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { cache, useReducer, useState } from "react";
|
||||
|
||||
const test = cache((id) => {
|
||||
const alertCounter = cache((id) => {
|
||||
alert(id);
|
||||
});
|
||||
|
||||
function App() {
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [_, rerender] = useReducer(() => ({}), {});
|
||||
test(counter);
|
||||
|
||||
alertCounter(counter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCounter((v) => v + 1)}>Add to {counter}</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>React Reactivity</title>
|
||||
<title>React Cache Error</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@unicorn-utterances/react-reactivity",
|
||||
"name": "@unicorn-utterances/react-cache-error",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@unicorn-utterances/react-reactivity",
|
||||
"name": "@unicorn-utterances/react-cache-error",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "18.3.0-canary-0cdfef19b-20231211",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@unicorn-utterances/react-reactivity",
|
||||
"name": "@unicorn-utterances/react-cache-error",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { cache, useReducer, useState } from "react";
|
||||
|
||||
const getIsEvenOrOdd = cache(
|
||||
(number) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(number % 2 === 0 ? "Even" : "Odd");
|
||||
}, 2000);
|
||||
}),
|
||||
);
|
||||
|
||||
function App() {
|
||||
const [counter, setCounter] = useState(0);
|
||||
|
||||
const isEvenOrOdd = getIsEvenOrOdd(counter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCounter((v) => v + 1)}>Add to {counter}</button>
|
||||
<p>
|
||||
{counter} is {isEvenOrOdd}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
@@ -1,6 +1,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>React Reactivity</title>
|
||||
<title>React Theme Cache</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@unicorn-utterances/react-reactivity",
|
||||
"name": "@unicorn-utterances/react-theme-cache",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
46
content/blog/explaining-reacts-cache-function/react-theme-cache/src/colors.js
vendored
Normal file
46
content/blog/explaining-reacts-cache-function/react-theme-cache/src/colors.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
function hexToRgb(hex) {
|
||||
// Remove the hash character
|
||||
hex = hex.replace(/^#/, "");
|
||||
|
||||
// Parse the hex value into separate R, G, B values
|
||||
const bigint = parseInt(hex, 16);
|
||||
const r = (bigint >> 16) & 255;
|
||||
const g = (bigint >> 8) & 255;
|
||||
const b = bigint & 255;
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
// Convert RGB values to a hex color code
|
||||
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
export function generateComplimentaryColors(hexColor) {
|
||||
const baseColor = hexToRgb(hexColor);
|
||||
|
||||
// Calculate the complementary color by inverting each RGB component
|
||||
const complimentary1 = rgbToHex(
|
||||
255 - baseColor.r,
|
||||
255 - baseColor.g,
|
||||
255 - baseColor.b,
|
||||
);
|
||||
|
||||
// Optionally, you can adjust the following multipliers for different shades
|
||||
const multiplier = 0.8;
|
||||
const complimentary3 = rgbToHex(
|
||||
Math.floor(baseColor.r * (1 + multiplier)),
|
||||
Math.floor(baseColor.g * (1 + multiplier)),
|
||||
Math.floor(baseColor.b * (1 + multiplier)),
|
||||
);
|
||||
|
||||
return [complimentary1, complimentary3];
|
||||
}
|
||||
|
||||
export const getReadableColor = (hexcolor) => {
|
||||
const r = parseInt(hexcolor.substr(1, 2), 16);
|
||||
const g = parseInt(hexcolor.substr(3, 2), 16);
|
||||
const b = parseInt(hexcolor.substr(5, 2), 16);
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq >= 128 ? "#000000" : "#ffffff";
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { cache, useState } from "react";
|
||||
import { generateComplimentaryColors, getReadableColor } from "./colors.js";
|
||||
import "./style.css";
|
||||
|
||||
const getTheme = cache((primaryColor) => {
|
||||
// Theoretically, this could get very expensive to compute
|
||||
// Depending on how many colors and how accurately
|
||||
const [secondaryColor, tertiaryColor] =
|
||||
generateComplimentaryColors(primaryColor);
|
||||
return {
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
tertiaryColor,
|
||||
primaryTextColor: getReadableColor(primaryColor),
|
||||
secondaryTextColor: getReadableColor(secondaryColor),
|
||||
tertiaryTextColor: getReadableColor(tertiaryColor),
|
||||
};
|
||||
});
|
||||
|
||||
const capitalize = (str) => str[0].toUpperCase() + str.slice(1);
|
||||
|
||||
function ThemePreviewRow({ type, themeColor }) {
|
||||
// The calculations to get the theme only occur once, even though this is
|
||||
// called in multiple component instances.
|
||||
const theme = getTheme(themeColor);
|
||||
return (
|
||||
<tr>
|
||||
<th>{capitalize(type)}</th>
|
||||
<td>
|
||||
<div
|
||||
className="colorBox"
|
||||
style={{
|
||||
backgroundColor: theme[type + "Color"],
|
||||
color: theme[type + "TextColor"],
|
||||
}}
|
||||
>
|
||||
Some Text
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [themeColor, setThemeColor] = useState("#7e38ff");
|
||||
const [tempColor, setTempColor] = useState(themeColor);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="spaceBottom">
|
||||
<div className="spaceBottom">
|
||||
<label>
|
||||
<div className="spaceBottom">Primary color</div>
|
||||
<input
|
||||
type="color"
|
||||
id="body"
|
||||
name="body"
|
||||
value={tempColor}
|
||||
onChange={(e) => setTempColor(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => setThemeColor(tempColor)}>Set theme</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<table>
|
||||
<tbody>
|
||||
<ThemePreviewRow type="primary" themeColor={themeColor} />
|
||||
<ThemePreviewRow type="secondary" themeColor={themeColor} />
|
||||
<ThemePreviewRow type="tertiary" themeColor={themeColor} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
@@ -0,0 +1,16 @@
|
||||
.spaceBottom {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.colorBox {
|
||||
margin-left: 0.5rem;
|
||||
min-height: 25px;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
}
|
||||
BIN
content/blog/explaining-reacts-cache-function/theme_preview.png
Normal file
BIN
content/blog/explaining-reacts-cache-function/theme_preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Reference in New Issue
Block a user