From e2e614e35b0b0d953a8c5794aa59bc1d2ff9e621 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 4 Mar 2024 17:18:28 -0700 Subject: [PATCH] finish exercise 2 --- .../05.problem.cb/README.mdx | 43 ++++++ .../05.problem.cb/index.css | 89 +++++++++++ .../05.problem.cb/index.tsx | 103 +++++++++++++ .../05.solution.cb/README.mdx | 4 + .../05.solution.cb/index.css | 89 +++++++++++ .../05.solution.cb/index.tsx | 100 ++++++++++++ .../01.problem.effects/README.mdx | 74 +++++++++ .../01.problem.effects/index.css | 89 +++++++++++ .../01.problem.effects/index.tsx | 112 ++++++++++++++ .../01.solution.effects/README.mdx | 6 + .../01.solution.effects/index.css | 89 +++++++++++ .../01.solution.effects/index.tsx | 111 ++++++++++++++ .../02.problem.cleanup/README.mdx | 67 ++++++++ .../02.problem.cleanup/index.css | 89 +++++++++++ .../02.problem.cleanup/index.tsx | 141 +++++++++++++++++ .../02.solution.cleanup/README.mdx | 4 + .../02.solution.cleanup/index.css | 89 +++++++++++ .../02.solution.cleanup/index.tsx | 144 ++++++++++++++++++ exercises/02.side-effects/FINISHED.mdx | 7 + exercises/02.side-effects/README.mdx | 105 +++++++++++++ .../01.solution.lift/index.tsx | 2 +- .../02.problem.lift-array/index.tsx | 2 +- .../02.solution.lift-array/index.tsx | 2 +- .../03.problem.colocate/index.tsx | 2 +- .../03.solution.colocate/index.tsx | 2 +- 25 files changed, 1560 insertions(+), 5 deletions(-) create mode 100644 exercises/01.managing-ui-state/05.problem.cb/README.mdx create mode 100644 exercises/01.managing-ui-state/05.problem.cb/index.css create mode 100644 exercises/01.managing-ui-state/05.problem.cb/index.tsx create mode 100644 exercises/01.managing-ui-state/05.solution.cb/README.mdx create mode 100644 exercises/01.managing-ui-state/05.solution.cb/index.css create mode 100644 exercises/01.managing-ui-state/05.solution.cb/index.tsx create mode 100644 exercises/02.side-effects/01.problem.effects/README.mdx create mode 100644 exercises/02.side-effects/01.problem.effects/index.css create mode 100644 exercises/02.side-effects/01.problem.effects/index.tsx create mode 100644 exercises/02.side-effects/01.solution.effects/README.mdx create mode 100644 exercises/02.side-effects/01.solution.effects/index.css create mode 100644 exercises/02.side-effects/01.solution.effects/index.tsx create mode 100644 exercises/02.side-effects/02.problem.cleanup/README.mdx create mode 100644 exercises/02.side-effects/02.problem.cleanup/index.css create mode 100644 exercises/02.side-effects/02.problem.cleanup/index.tsx create mode 100644 exercises/02.side-effects/02.solution.cleanup/README.mdx create mode 100644 exercises/02.side-effects/02.solution.cleanup/index.css create mode 100644 exercises/02.side-effects/02.solution.cleanup/index.tsx create mode 100644 exercises/02.side-effects/FINISHED.mdx create mode 100644 exercises/02.side-effects/README.mdx diff --git a/exercises/01.managing-ui-state/05.problem.cb/README.mdx b/exercises/01.managing-ui-state/05.problem.cb/README.mdx new file mode 100644 index 000000000..e7dccc16e --- /dev/null +++ b/exercises/01.managing-ui-state/05.problem.cb/README.mdx @@ -0,0 +1,43 @@ +# Init Callback + +πŸ¦‰ There's one more thing you should know about `useState` initialization and +that is a small performance optimization. `useState` can accept a function. + +You may recall from earlier we mentioned that the first argument to `useState` +is only used during the initial render. It's not used on subsequent renders. +This is because the initial value is only used when the component is first +rendered. After that, the value is managed by React and you use the updater +function to update it. + +But imagine a situation where calculating that initial value were +computationally expensive. It would be a waste to compute the initial value for +all but the initial render right? That's where the function form of `useState` +comes in. + +Let's imagine we have a function that calculates the initial value and it's +computationally expensive: + +```tsx +const [val, setVal] = useState(calculateInitialValue()) +``` + +This will work just fine, but it's not ideal. The `calculateInitialValue` will +be called on every render, even though it's only needed for the initial render. +So instead of calling the function, we can just pass it: + +```tsx +const [val, setVal] = useState(calculateInitialValue) +``` + +Typically doing this is unnecessary, but it's good to know about in case you +need it. + +So + +```tsx +// both of these work just fine: +const [query, setQuery] = useState(getQueryParam()) +const [query, setQuery] = useState(getQueryParam) +``` + +You're going to be making the `getQueryParam` function. Got it? Great, let's go! diff --git a/exercises/01.managing-ui-state/05.problem.cb/index.css b/exercises/01.managing-ui-state/05.problem.cb/index.css new file mode 100644 index 000000000..0cdfd44a3 --- /dev/null +++ b/exercises/01.managing-ui-state/05.problem.cb/index.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; +} + +.app { + margin: 40px auto; + max-width: 1024px; + form { + text-align: center; + } +} + +.post-list { + list-style: none; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + li { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #ddd; + width: 320px; + transition: transform 0.2s ease-in-out; + a { + text-decoration: none; + color: unset; + } + + &:hover, + &:has(*:focus), + &:has(*:active) { + transform: translate(0px, -6px); + } + + .post-image { + display: block; + width: 100%; + height: 200px; + } + + button { + position: absolute; + font-size: 1.5rem; + top: 20px; + right: 20px; + background: transparent; + border: none; + outline: none; + &:hover, + &:focus, + &:active { + animation: pulse 1.5s infinite; + } + } + + a { + padding: 10px 10px; + display: flex; + gap: 8px; + flex-direction: column; + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; + } + p { + margin: 0; + font-size: 1rem; + color: #666; + } + } + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/exercises/01.managing-ui-state/05.problem.cb/index.tsx b/exercises/01.managing-ui-state/05.problem.cb/index.tsx new file mode 100644 index 000000000..54d77ed50 --- /dev/null +++ b/exercises/01.managing-ui-state/05.problem.cb/index.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react' +import * as ReactDOM from 'react-dom/client' +import { generateGradient, getMatchingPosts } from '#shared/blog-posts' + +// 🐨 make a function here called getQueryParam + +function App() { + // 🐨 move πŸ‘‡ up to getQueryParam + const params = new URLSearchParams(window.location.search) + const initialQuery = params.get('query') ?? '' + // 🐨 move πŸ‘† up to getQueryParam and return the initialQuery + + // 🐨 pass getQueryParam into useState + const [query, setQuery] = useState(initialQuery) + const words = query.split(' ') + + const dogChecked = words.includes('dog') + const catChecked = words.includes('cat') + const caterpillarChecked = words.includes('caterpillar') + + function handleCheck(tag: string, checked: boolean) { + const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) + setQuery(newWords.filter(Boolean).join(' ').trim()) + } + + return ( +
+
+
+ + setQuery(e.currentTarget.value)} + /> +
+
+ + + +
+ +
+ +
+ ) +} + +function MatchingPosts({ query }: { query: string }) { + const matchingPosts = getMatchingPosts(query) + + return ( + + ) +} + +const rootEl = document.createElement('div') +document.body.append(rootEl) +ReactDOM.createRoot(rootEl).render() diff --git a/exercises/01.managing-ui-state/05.solution.cb/README.mdx b/exercises/01.managing-ui-state/05.solution.cb/README.mdx new file mode 100644 index 000000000..b9a278f4e --- /dev/null +++ b/exercises/01.managing-ui-state/05.solution.cb/README.mdx @@ -0,0 +1,4 @@ +# Init Callback + +πŸ‘¨β€πŸ’Ό Great! This isn't 100% necessary as a performance optimization, but it's easy +and doesn't hurt readability so we may as well! diff --git a/exercises/01.managing-ui-state/05.solution.cb/index.css b/exercises/01.managing-ui-state/05.solution.cb/index.css new file mode 100644 index 000000000..0cdfd44a3 --- /dev/null +++ b/exercises/01.managing-ui-state/05.solution.cb/index.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; +} + +.app { + margin: 40px auto; + max-width: 1024px; + form { + text-align: center; + } +} + +.post-list { + list-style: none; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + li { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #ddd; + width: 320px; + transition: transform 0.2s ease-in-out; + a { + text-decoration: none; + color: unset; + } + + &:hover, + &:has(*:focus), + &:has(*:active) { + transform: translate(0px, -6px); + } + + .post-image { + display: block; + width: 100%; + height: 200px; + } + + button { + position: absolute; + font-size: 1.5rem; + top: 20px; + right: 20px; + background: transparent; + border: none; + outline: none; + &:hover, + &:focus, + &:active { + animation: pulse 1.5s infinite; + } + } + + a { + padding: 10px 10px; + display: flex; + gap: 8px; + flex-direction: column; + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; + } + p { + margin: 0; + font-size: 1rem; + color: #666; + } + } + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/exercises/01.managing-ui-state/05.solution.cb/index.tsx b/exercises/01.managing-ui-state/05.solution.cb/index.tsx new file mode 100644 index 000000000..b761821eb --- /dev/null +++ b/exercises/01.managing-ui-state/05.solution.cb/index.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import * as ReactDOM from 'react-dom/client' +import { generateGradient, getMatchingPosts } from '#shared/blog-posts' + +function getQueryParam() { + const params = new URLSearchParams(window.location.search) + return params.get('query') ?? '' +} + +function App() { + const [query, setQuery] = useState(getQueryParam) + const words = query.split(' ') + + const dogChecked = words.includes('dog') + const catChecked = words.includes('cat') + const caterpillarChecked = words.includes('caterpillar') + + function handleCheck(tag: string, checked: boolean) { + const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) + setQuery(newWords.filter(Boolean).join(' ').trim()) + } + + return ( +
+
+
+ + setQuery(e.currentTarget.value)} + /> +
+
+ + + +
+ +
+ +
+ ) +} + +function MatchingPosts({ query }: { query: string }) { + const matchingPosts = getMatchingPosts(query) + + return ( + + ) +} + +const rootEl = document.createElement('div') +document.body.append(rootEl) +ReactDOM.createRoot(rootEl).render() diff --git a/exercises/02.side-effects/01.problem.effects/README.mdx b/exercises/02.side-effects/01.problem.effects/README.mdx new file mode 100644 index 000000000..c9cd6d072 --- /dev/null +++ b/exercises/02.side-effects/01.problem.effects/README.mdx @@ -0,0 +1,74 @@ +# useEffect + +πŸ‘¨β€πŸ’Ό We want the query to update as the query params change through a `popstate` +event. Once you added the `useEffect` hook, you then have to prevent the default +full refresh of the page when the user clicks the back/forward button. + +πŸ§β€β™‚οΈ I added a new utility called `setGlobalSearchParams` which allows us to set +the URL search params without triggering a full-page refresh. So whenever the +user clicks "submit," we update the URL search params. The trouble is, when they +hit the back button, the search doesn't stay synchronoized with the URL (yet). + +You can take a look at my changes for +details on what I did. + +πŸ‘¨β€πŸ’Ό Thanks Kellie. So what we you to do is make it so when the user hits the back +button in their browser and the search params are updated, we update the input +value. + +The only way to be notified of a back/forward event is to listen for the +`popstate` event. You can do this by adding an event listener to the `window` +object. + +```javascript +window.addEventListener('popstate', () => { + // update the input value +}) +``` + +To test whether you got this right follow these steps: + +- Go to (in a new tab) +- Add " cat" in the input +- Click "submit" +- Click the back button in your browser +- The input value should now be "dog" + +That last step will be broken and that's what you should fix in this step. + + + Spoiler alert: there's going to be a bug with our implementation that we'll + fix in the next step, so if you notice a bug... we'll get to it πŸ˜… + + +One more thing, we're going to need to retrieve the query param in two places. +Right now we get it to initialize our state, but we'll also want to get it when +the `popstate` event fires. So you'll be extracting that logic to a small +function. This will introduce another feature of React's `useState` hook that +we'll take advantage of for the first time and that is the callback form of +`useState`. So you should know that these are equivalent: + +```tsx +const [val, setVal] = useState('hello') +// will result in the same thing as: +const [val, setVal] = useState(function getHello() { + return 'hello' +}) +``` + +What's the difference? The second form is useful when you creating the initial +state is computationally expensive. So it's a performance optimization because +it allows you to place the expensive stuff inside the function and then it will +only be called during the initial render. This is a bit of a tangent, but I +thought it was worth mentioning. + +Typically it's unnecessary, but since we're making a function anyway, you can +simply pass the function to `useState` directly: + +```tsx +// both of these work just fine: +const [query, setQuery] = useState(getQueryParam()) +const [query, setQuery] = useState(getQueryParam) +``` + +You're going to be making the `getQueryParam` function. Got it? Great, let's go! diff --git a/exercises/02.side-effects/01.problem.effects/index.css b/exercises/02.side-effects/01.problem.effects/index.css new file mode 100644 index 000000000..0cdfd44a3 --- /dev/null +++ b/exercises/02.side-effects/01.problem.effects/index.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; +} + +.app { + margin: 40px auto; + max-width: 1024px; + form { + text-align: center; + } +} + +.post-list { + list-style: none; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + li { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #ddd; + width: 320px; + transition: transform 0.2s ease-in-out; + a { + text-decoration: none; + color: unset; + } + + &:hover, + &:has(*:focus), + &:has(*:active) { + transform: translate(0px, -6px); + } + + .post-image { + display: block; + width: 100%; + height: 200px; + } + + button { + position: absolute; + font-size: 1.5rem; + top: 20px; + right: 20px; + background: transparent; + border: none; + outline: none; + &:hover, + &:focus, + &:active { + animation: pulse 1.5s infinite; + } + } + + a { + padding: 10px 10px; + display: flex; + gap: 8px; + flex-direction: column; + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; + } + p { + margin: 0; + font-size: 1rem; + color: #666; + } + } + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/exercises/02.side-effects/01.problem.effects/index.tsx b/exercises/02.side-effects/01.problem.effects/index.tsx new file mode 100644 index 000000000..f60bd834f --- /dev/null +++ b/exercises/02.side-effects/01.problem.effects/index.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import * as ReactDOM from 'react-dom/client' +import { generateGradient, getMatchingPosts } from '#shared/blog-posts' +import { setGlobalSearchParams } from '#shared/utils' + +function getQueryParam() { + const params = new URLSearchParams(window.location.search) + return params.get('query') ?? '' +} + +function App() { + const [query, setQuery] = useState(getQueryParam) + + const words = query.split(' ') + + const dogChecked = words.includes('dog') + const catChecked = words.includes('cat') + const caterpillarChecked = words.includes('caterpillar') + + // 🐨 add a useEffect(() => {}, []) call here (we'll talk about that empty array later) + // 🐨 in the useEffect callback, subscribe to window's popstate event + // 🐨 your event handler should call setQuery to getQueryParam() + // πŸ“œ https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + + function handleCheck(tag: string, checked: boolean) { + const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) + setQuery(newWords.filter(Boolean).join(' ').trim()) + } + + return ( +
+
{ + e.preventDefault() + setGlobalSearchParams({ query }) + }} + > +
+ + setQuery(e.currentTarget.value)} + /> +
+
+ + + +
+ +
+ +
+ ) +} + +function MatchingPosts({ query }: { query: string }) { + const matchingPosts = getMatchingPosts(query) + + return ( + + ) +} + +const rootEl = document.createElement('div') +document.body.append(rootEl) +ReactDOM.createRoot(rootEl).render() diff --git a/exercises/02.side-effects/01.solution.effects/README.mdx b/exercises/02.side-effects/01.solution.effects/README.mdx new file mode 100644 index 000000000..2567de3d6 --- /dev/null +++ b/exercises/02.side-effects/01.solution.effects/README.mdx @@ -0,0 +1,6 @@ +# useEffect + +πŸ‘¨β€πŸ’Ό Great! Now you know how to make sure side-effects like global event listeners +can be registered from within a React component and how to integrate that with +the state of your React component. But we've still got a bug, so let's get to +that. diff --git a/exercises/02.side-effects/01.solution.effects/index.css b/exercises/02.side-effects/01.solution.effects/index.css new file mode 100644 index 000000000..0cdfd44a3 --- /dev/null +++ b/exercises/02.side-effects/01.solution.effects/index.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; +} + +.app { + margin: 40px auto; + max-width: 1024px; + form { + text-align: center; + } +} + +.post-list { + list-style: none; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + li { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #ddd; + width: 320px; + transition: transform 0.2s ease-in-out; + a { + text-decoration: none; + color: unset; + } + + &:hover, + &:has(*:focus), + &:has(*:active) { + transform: translate(0px, -6px); + } + + .post-image { + display: block; + width: 100%; + height: 200px; + } + + button { + position: absolute; + font-size: 1.5rem; + top: 20px; + right: 20px; + background: transparent; + border: none; + outline: none; + &:hover, + &:focus, + &:active { + animation: pulse 1.5s infinite; + } + } + + a { + padding: 10px 10px; + display: flex; + gap: 8px; + flex-direction: column; + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; + } + p { + margin: 0; + font-size: 1rem; + color: #666; + } + } + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/exercises/02.side-effects/01.solution.effects/index.tsx b/exercises/02.side-effects/01.solution.effects/index.tsx new file mode 100644 index 000000000..ed0a52176 --- /dev/null +++ b/exercises/02.side-effects/01.solution.effects/index.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import * as ReactDOM from 'react-dom/client' +import { generateGradient, getMatchingPosts } from '#shared/blog-posts' +import { setGlobalSearchParams } from '#shared/utils' + +function getQueryParam() { + const params = new URLSearchParams(window.location.search) + return params.get('query') ?? '' +} + +function App() { + const [query, setQuery] = useState(getQueryParam) + + const words = query.split(' ') + + const dogChecked = words.includes('dog') + const catChecked = words.includes('cat') + const caterpillarChecked = words.includes('caterpillar') + + useEffect(() => { + window.addEventListener('popstate', () => setQuery(getQueryParam())) + }, []) + + function handleCheck(tag: string, checked: boolean) { + const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) + setQuery(newWords.filter(Boolean).join(' ').trim()) + } + + return ( +
+
{ + e.preventDefault() + setGlobalSearchParams({ query }) + }} + > +
+ + setQuery(e.currentTarget.value)} + /> +
+
+ + + +
+ +
+ +
+ ) +} + +function MatchingPosts({ query }: { query: string }) { + const matchingPosts = getMatchingPosts(query) + + return ( + + ) +} + +const rootEl = document.createElement('div') +document.body.append(rootEl) +ReactDOM.createRoot(rootEl).render() diff --git a/exercises/02.side-effects/02.problem.cleanup/README.mdx b/exercises/02.side-effects/02.problem.cleanup/README.mdx new file mode 100644 index 000000000..cbaa147e7 --- /dev/null +++ b/exercises/02.side-effects/02.problem.cleanup/README.mdx @@ -0,0 +1,67 @@ +# Effect Cleanup + +πŸ‘¨β€πŸ’Ό We've got an issue with our `useEffect` callback here that needs some +attention. You won't be able to tell without a little bit extra in the app, so +Kellie (πŸ§β€β™‚οΈ) has put together a demo. + +πŸ§β€β™‚οΈ Yep, so now we have a checkbox that says "show form." When you check it, +it'll show the form and the results, when you uncheck it, those will be removed. +In dynamic applications we have components that are added and removed from the +page all the time, so you definitelly will have situations like this. + +πŸ‘¨β€πŸ’Ό Thanks Kellie. Olivia (πŸ¦‰) would like to talk to you about memory leaks. + +πŸ¦‰ Thanks Peter. So, let's review what's going on. When our component is +rendered, we subscribe to the `popstate` event. The callback we pass to the +`addEventListener` method creates a closure over all the variables in the +function's scope. This means that when the callback is called, it has access to +those values. What that means is that as long as that function exists and is +referenced by something else in the application, those values will be kept in +memory as well just in case the callback is called again. + +As a result, when the component is removed from the page, the callback is still +referenced by the `popstate` event, and so the values are kept in memory. So +imagine if you have a component that is added and removed from the page many +times, and each time it's added, it subscribes to an event and adds more to the +memory, but that memory is never released because even when the component is +removed from the page the event still has a reference to the callback which is +hanging on to all the values! + +This is called a memory leak and will make your application slower and use more +memory than it needs to (leading to a bad user experience). Whether you're using +React or anything else, you should always be aware of memory leaks and how to +avoid them. In general, whenever you find yourself adding an event listener or +subscribing to something, you should always make sure to remove that listener or +subscription when you're finished with it. + +So in a React context, this means that you should always clean up your effects +when the component is removed from the page. The way to do this is to return a +function from the effect that removes the listener or subscription: + +```tsx +useEffect(() => { + function handleEvent() { + // some-event happened! + } + window.addEventListener('some-event', handleEvent) + return () => { + window.removeEventListener('some-event', handleEvent) + } +}, []) +``` + +This way, when the component is removed from the page, React will call the +cleanup function and remove the listener or subscription. + +πŸ‘¨β€πŸ’Ό Great. Now that we've got that out of the way, let's handle this in our app. + +You can add `console.log` statements to make sure things are being called (unless +you want to open up the memory profiling tab in your dev tools and click the +checkbox a bunch of times to see the memory usage go up πŸ˜…). + +πŸ’― If you really do want to test this out, I've added a couple lines you can +uncomment to allocate huge amounts of memory to huge arrays. If you uncomment +those, then watch the quick climb of the memory in the +[Memory tab of dev tools](https://developer.chrome.com/docs/devtools/memory) or +[Browser Task manager](https://developer.chrome.com/docs/devtools/memory-problems#monitor_memory_use_in_realtime_with_the_chrome_task_manager) +every time you check and uncheck the box. diff --git a/exercises/02.side-effects/02.problem.cleanup/index.css b/exercises/02.side-effects/02.problem.cleanup/index.css new file mode 100644 index 000000000..0cdfd44a3 --- /dev/null +++ b/exercises/02.side-effects/02.problem.cleanup/index.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; +} + +.app { + margin: 40px auto; + max-width: 1024px; + form { + text-align: center; + } +} + +.post-list { + list-style: none; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + li { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #ddd; + width: 320px; + transition: transform 0.2s ease-in-out; + a { + text-decoration: none; + color: unset; + } + + &:hover, + &:has(*:focus), + &:has(*:active) { + transform: translate(0px, -6px); + } + + .post-image { + display: block; + width: 100%; + height: 200px; + } + + button { + position: absolute; + font-size: 1.5rem; + top: 20px; + right: 20px; + background: transparent; + border: none; + outline: none; + &:hover, + &:focus, + &:active { + animation: pulse 1.5s infinite; + } + } + + a { + padding: 10px 10px; + display: flex; + gap: 8px; + flex-direction: column; + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; + } + p { + margin: 0; + font-size: 1rem; + color: #666; + } + } + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/exercises/02.side-effects/02.problem.cleanup/index.tsx b/exercises/02.side-effects/02.problem.cleanup/index.tsx new file mode 100644 index 000000000..ca9fdf2e7 --- /dev/null +++ b/exercises/02.side-effects/02.problem.cleanup/index.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react' +import * as ReactDOM from 'react-dom/client' +import { generateGradient, getMatchingPosts } from '#shared/blog-posts' +import { setGlobalSearchParams } from '#shared/utils' + +function getQueryParam() { + const params = new URLSearchParams(window.location.search) + return params.get('query') ?? '' +} + +function App() { + const [query, setQuery] = useState(getQueryParam) + + const words = query.split(' ') + + const dogChecked = words.includes('dog') + const catChecked = words.includes('cat') + const caterpillarChecked = words.includes('caterpillar') + + useEffect(() => { + // πŸ’― you can use this to test whether your cleanup is working (make sure to include the console.log below as well) + // const hugeData = new Array(1_000_000).fill(new Array(1_000_000).fill('πŸΆπŸ±πŸ›')) + + // 🐨 extract your event handler here into a function called updateQuery + window.addEventListener('popstate', () => { + // πŸ’― you can use this to test whether your cleanup is freeing up memory + // console.log(hugeData) + + console.log('popstate event listener called') + setQuery(getQueryParam()) + }) + // 🐨 return a function which removes the popstate event listener + // πŸ“œ https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener + }, []) + + function handleCheck(tag: string, checked: boolean) { + const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) + setQuery(newWords.filter(Boolean).join(' ').trim()) + } + + return ( +
+
{ + e.preventDefault() + setGlobalSearchParams({ query }) + }} + > +
+ + setQuery(e.currentTarget.value)} + /> +
+
+ + + +
+ +
+ +
+ ) +} + +function MatchingPosts({ query }: { query: string }) { + const matchingPosts = getMatchingPosts(query) + + return ( + + ) +} + +function DemoApp() { + const [showForm, setShowForm] = useState(true) + + return ( +
+ + {showForm ? : null} +
+ ) +} + +const rootEl = document.createElement('div') +document.body.append(rootEl) +ReactDOM.createRoot(rootEl).render() diff --git a/exercises/02.side-effects/02.solution.cleanup/README.mdx b/exercises/02.side-effects/02.solution.cleanup/README.mdx new file mode 100644 index 000000000..9d257ca1b --- /dev/null +++ b/exercises/02.side-effects/02.solution.cleanup/README.mdx @@ -0,0 +1,4 @@ +# Effect Cleanup + +πŸ‘¨β€πŸ’Ό Phew! I'm glad we're not going to run into memory leaks now. Our users +(especially) those on low-end devices will thank us for it! diff --git a/exercises/02.side-effects/02.solution.cleanup/index.css b/exercises/02.side-effects/02.solution.cleanup/index.css new file mode 100644 index 000000000..0cdfd44a3 --- /dev/null +++ b/exercises/02.side-effects/02.solution.cleanup/index.css @@ -0,0 +1,89 @@ +html, +body { + margin: 0; +} + +.app { + margin: 40px auto; + max-width: 1024px; + form { + text-align: center; + } +} + +.post-list { + list-style: none; + padding: 0; + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + li { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #ddd; + width: 320px; + transition: transform 0.2s ease-in-out; + a { + text-decoration: none; + color: unset; + } + + &:hover, + &:has(*:focus), + &:has(*:active) { + transform: translate(0px, -6px); + } + + .post-image { + display: block; + width: 100%; + height: 200px; + } + + button { + position: absolute; + font-size: 1.5rem; + top: 20px; + right: 20px; + background: transparent; + border: none; + outline: none; + &:hover, + &:focus, + &:active { + animation: pulse 1.5s infinite; + } + } + + a { + padding: 10px 10px; + display: flex; + gap: 8px; + flex-direction: column; + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; + } + p { + margin: 0; + font-size: 1rem; + color: #666; + } + } + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/exercises/02.side-effects/02.solution.cleanup/index.tsx b/exercises/02.side-effects/02.solution.cleanup/index.tsx new file mode 100644 index 000000000..5a7edfca1 --- /dev/null +++ b/exercises/02.side-effects/02.solution.cleanup/index.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from 'react' +import * as ReactDOM from 'react-dom/client' +import { generateGradient, getMatchingPosts } from '#shared/blog-posts' +import { setGlobalSearchParams } from '#shared/utils' + +function getQueryParam() { + const params = new URLSearchParams(window.location.search) + return params.get('query') ?? '' +} + +function App() { + const [query, setQuery] = useState(getQueryParam) + + const words = query.split(' ') + + const dogChecked = words.includes('dog') + const catChecked = words.includes('cat') + const caterpillarChecked = words.includes('caterpillar') + + useEffect(() => { + // πŸ’― this is here for you to test that the memory is freed up when the + // component is unmounted. + const hugeData = new Array(1_000_000).fill( + new Array(1_000_000).fill('πŸΆπŸ±πŸ›'), + ) + function updateQuery() { + // πŸ’― this is here to make sure the function hangs onto the reference with + // hugeData (because browsers can be pretty clever with their JIT optimizations) + console.log(hugeData) + console.log('popstate event listener called') + setQuery(getQueryParam()) + } + window.addEventListener('popstate', updateQuery) + return () => { + window.removeEventListener('popstate', updateQuery) + } + }, []) + + function handleCheck(tag: string, checked: boolean) { + const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) + setQuery(newWords.filter(Boolean).join(' ').trim()) + } + + return ( +
+
{ + e.preventDefault() + setGlobalSearchParams({ query }) + }} + > +
+ + setQuery(e.currentTarget.value)} + /> +
+
+ + + +
+ +
+ +
+ ) +} + +function MatchingPosts({ query }: { query: string }) { + const matchingPosts = getMatchingPosts(query) + + return ( + + ) +} + +function DemoApp() { + const [showForm, setShowForm] = useState(true) + + return ( +
+ + {showForm ? : null} +
+ ) +} + +const rootEl = document.createElement('div') +document.body.append(rootEl) +ReactDOM.createRoot(rootEl).render() diff --git a/exercises/02.side-effects/FINISHED.mdx b/exercises/02.side-effects/FINISHED.mdx new file mode 100644 index 000000000..134c6685a --- /dev/null +++ b/exercises/02.side-effects/FINISHED.mdx @@ -0,0 +1,7 @@ +# Side-Effects + +πŸ‘¨β€πŸ’Ό Great work! You've learned the basics of how to trigger side-effects when our +component is added to the page and how to properly clean up when it's through. +You've not learned everything there is to know about `useEffect` yet (that +dependency array is still a topic we need to get into), but we'll get into the +rest of that soon. diff --git a/exercises/02.side-effects/README.mdx b/exercises/02.side-effects/README.mdx new file mode 100644 index 000000000..a13e8b984 --- /dev/null +++ b/exercises/02.side-effects/README.mdx @@ -0,0 +1,105 @@ +# Side-Effects + +`useEffect` is a built-in hook that allows you to run some custom code +after React renders (and re-renders) your component to the DOM. It accepts a +callback function which React will call after the DOM has been updated: + +```javascript +useEffect(() => { + // your side-effect code here. + // this is where you can interact with browser APIs for example + doSomeThing() + return function cleanup() { + // if you need to clean up after your side-effect (like unsubscribe from an + // event), you can do it here + doSomeCleanup() + } +}, [ + // this is where dependencies of your useEffect callback go + // we'll talk about this in depth in a future exercise. + // In this exercise, we'll just leave it as an empty array + dep1, + dep2, +]) +``` + +`useState` is for managing our react component state and `useEffect` is for +managing side-effects. Side-effects are things that happen outside our react +components. + +For example, things outside our react components include: + +- Browser APIs like local storage, geolocation, media devices, etc. +- Integrations with third-party libraries like D3, Chart.js, etc. + +Check out [the React Flow diagram](https://github.com/donavon/hook-flow) below: + +![React Flow diagram showing mount, update, unmount](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/295689283-b9ecdd1d-ce28-446b-84ad-6b264d4be8e4.png) + +The graphic illustrates the lifecycle of a React component, focusing on how +hooks behave during different phases: Mount, Update, and Unmount. It's +structured into different sections, each representing a stage in the component +lifecycle, and provides a visual flow of the order in which React hooks and +other operations are executed. Here's a breakdown: + +1. **Mount Phase**: + - **Run lazy initializers**: This step involves executing any lazy + initialization functions provided to hooks like `useState` or `useReducer`. + These functions are only run during the initial render. +2. **Update Phase** (triggered by a parent re-render, state change, or context + change): + - **Render**: The component re-renders, evaluating its function body. + - **React updates DOM**: React applies any changes from the render phase to + the DOM. + - **Cleanup LayoutEffects**: Before running any new layout effects, React + cleans up the previous ones defined in `useLayoutEffect`. + - **Run LayoutEffects**: Runs the effects defined in `useLayoutEffect` + immediately after DOM updates, blocking the browser painting until + complete. + - **Browser paints screen**: The browser updates the visual representation of + the page. + - **Cleanup Effects**: Cleans up any effects defined in `useEffect` from the + previous render. + - **Run Effects**: Runs the effects defined in `useEffect`. These are + scheduled to run after the paint, so they don't block the browser from + updating the screen. +3. **Unmount Phase**: + - React performs cleanup for both `useEffect` and `useLayoutEffect` hooks, + preventing memory leaks by removing event listeners, canceling network + requests, or invalidating timers set up by the component. + +**Notes** at the bottom highlight key concepts: + +- **Updates** are triggered by re-renders from parent components, state changes, + or context changes. +- **Lazy initializers** are functions that initialize state lazily, meaning the + initial state is computed only on the initial render, potentially optimizing + performance. + +The different colors in the graphic signify various stages and types of +operations within the React component lifecycle, specifically relating to the +execution of hooks and rendering processes. Each color represents a distinct +group of operations: + +1. **Green (Top section)**: This color is associated with the initial setup + phase of a component, including running lazy initializers which are functions + provided to hooks like `useState` and `useReducer` for setting the initial + state. +2. **Red (Middle section)**: Represents operations related to the + DOM updates and the pre-paint phase. This includes the rendering process, + where React evaluates the component and updates the DOM, followed by the + cleanup and execution of layout effects (`useLayoutEffect`). These operations + are crucial for ensuring that any DOM manipulations or measurements happen + synchronously before the browser paints. +3. **Yellow (Bottom section)**: Focuses on post-paint effects, encapsulating the + cleanup and execution of side effects (`useEffect`). These operations are + scheduled after painting, allowing for non-blocking operations like data + fetching, subscriptions, or manually triggering DOM updates. These effects + run asynchronously to avoid delaying the visual update of the page. + +This diagram is a helpful reference for understanding the sequence and timing of +React's hook-based lifecycle methods, which is crucial for correctly managing +side effects, subscriptions, and manual DOM manipulations in functional +components. + +This will make more sense after finishing the exercise. So come back! diff --git a/exercises/03.lifting-state/01.solution.lift/index.tsx b/exercises/03.lifting-state/01.solution.lift/index.tsx index b2bb455c2..9b6a6b9bf 100644 --- a/exercises/03.lifting-state/01.solution.lift/index.tsx +++ b/exercises/03.lifting-state/01.solution.lift/index.tsx @@ -21,7 +21,7 @@ function App() { return () => { window.removeEventListener('popstate', updateQuery) } - }, [setQuery]) + }, []) return (
diff --git a/exercises/03.lifting-state/02.problem.lift-array/index.tsx b/exercises/03.lifting-state/02.problem.lift-array/index.tsx index 7a95b5306..80cd7e547 100644 --- a/exercises/03.lifting-state/02.problem.lift-array/index.tsx +++ b/exercises/03.lifting-state/02.problem.lift-array/index.tsx @@ -21,7 +21,7 @@ function App() { return () => { window.removeEventListener('popstate', updateQuery) } - }, [setQuery]) + }, []) return (
diff --git a/exercises/03.lifting-state/02.solution.lift-array/index.tsx b/exercises/03.lifting-state/02.solution.lift-array/index.tsx index 71f4c0a48..9caf43bd3 100644 --- a/exercises/03.lifting-state/02.solution.lift-array/index.tsx +++ b/exercises/03.lifting-state/02.solution.lift-array/index.tsx @@ -21,7 +21,7 @@ function App() { return () => { window.removeEventListener('popstate', updateQuery) } - }, [setQuery]) + }, []) return (
diff --git a/exercises/03.lifting-state/03.problem.colocate/index.tsx b/exercises/03.lifting-state/03.problem.colocate/index.tsx index 6e1f7c772..32656d5cd 100644 --- a/exercises/03.lifting-state/03.problem.colocate/index.tsx +++ b/exercises/03.lifting-state/03.problem.colocate/index.tsx @@ -21,7 +21,7 @@ function App() { return () => { window.removeEventListener('popstate', updateQuery) } - }, [setQuery]) + }, []) return (
diff --git a/exercises/03.lifting-state/03.solution.colocate/index.tsx b/exercises/03.lifting-state/03.solution.colocate/index.tsx index 67d7d24d6..19066efee 100644 --- a/exercises/03.lifting-state/03.solution.colocate/index.tsx +++ b/exercises/03.lifting-state/03.solution.colocate/index.tsx @@ -21,7 +21,7 @@ function App() { return () => { window.removeEventListener('popstate', updateQuery) } - }, [setQuery]) + }, []) return (