69 | )
70 | }
71 |
72 | export function ButtonWithTooltip({
73 | tooltipContent,
74 | ...rest
75 | }: React.DetailedHTMLProps<
76 | React.ButtonHTMLAttributes,
77 | HTMLButtonElement
78 | > & { tooltipContent: React.ReactNode }) {
79 | const [targetRect, setTargetRect] = useState(null)
80 | const buttonRef = useRef(null)
81 | function displayTooltip() {
82 | const rect = buttonRef.current?.getBoundingClientRect()
83 | if (!rect) return
84 | setTargetRect({
85 | left: rect.left,
86 | top: rect.top,
87 | right: rect.right,
88 | bottom: rect.bottom,
89 | })
90 | }
91 | const hideTooltip = () => setTargetRect(null)
92 | return (
93 | <>
94 |
102 | {targetRect ? (
103 | {tooltipContent}
104 | ) : null}
105 | >
106 | )
107 | }
108 |
--------------------------------------------------------------------------------
/exercises/05.portals/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Portals
2 |
3 |
4 |
5 | 👨💼 Nice and quick on, but useful tool in your React toolbelt!
6 |
--------------------------------------------------------------------------------
/exercises/05.portals/README.mdx:
--------------------------------------------------------------------------------
1 | # Portals
2 |
3 |
4 |
5 | There are a variety of UI patterns that require a component render some part of
6 | its UI that appears outside of the component's normal DOM hierarchy. For
7 | example, a modal dialog might need to render its contents at the root of the
8 | document, or a tooltip might need to render its contents at the end of the
9 | `body` element. Typically this is for the purpose of layering or positioning
10 | content above other content.
11 |
12 | You could imperatively add a `useEffect` that creates a DOM node yourself,
13 | appends it to the document, and then removes it when the component unmounts.
14 | However, this is a common enough pattern that React provides a built-in way to
15 | do this with the `ReactDOM.createPortal` method.
16 |
17 | ```tsx lines=1,12-18
18 | import { createPortal } from 'react-dom'
19 |
20 | function Modal({
21 | title,
22 | content,
23 | handleClose,
24 | }: {
25 | title: string
26 | content: string
27 | handleClose: () => void
28 | }) {
29 | return createPortal(
30 |
75 | )
76 | }
77 |
78 | export function ButtonWithTooltip({
79 | tooltipContent,
80 | ...rest
81 | }: React.DetailedHTMLProps<
82 | React.ButtonHTMLAttributes,
83 | HTMLButtonElement
84 | > & { tooltipContent: React.ReactNode }) {
85 | const [targetRect, setTargetRect] = useState(null)
86 | const buttonRef = useRef(null)
87 |
88 | function displayTooltip() {
89 | const rect = buttonRef.current?.getBoundingClientRect()
90 | if (!rect) return
91 | setTargetRect({
92 | left: rect.left,
93 | top: rect.top,
94 | right: rect.right,
95 | bottom: rect.bottom,
96 | })
97 | }
98 |
99 | const hideTooltip = () => setTargetRect(null)
100 |
101 | return (
102 | <>
103 |
111 | {targetRect ? (
112 | {tooltipContent}
113 | ) : null}
114 | >
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/exercises/06.layout-computation/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Layout Computation
2 |
3 |
4 |
5 | 👨💼 Hey... You're awesome. 😎
6 |
--------------------------------------------------------------------------------
/exercises/06.layout-computation/README.mdx:
--------------------------------------------------------------------------------
1 | # Layout Computation
2 |
3 |
4 |
5 | Sometimes you need to compute the layout of some UI before it is actually
6 | displayed. This is often necessary if the size, position, or location of your UI
7 | depends on the size, position, or location of the other elements on the page or
8 | even itself (like the contents of a tooltip).
9 |
10 | The trouble is, sometimes you don't know the size, position, or location of the
11 | other elements on the page until the layout has been computed. So what happens
12 | is you render the UI, then you make your measurements, then you re-render the UI
13 | with the new measurements. This is inefficient and can cause flickering.
14 |
15 | To avoid this problem in React, you can use the `useLayoutEffect` hook. This
16 | hook is designed with this specific use case in mind and is not a hook you'll
17 | find yourself needing very often.
18 |
19 | It literally has the same API as `useEffect`, but it runs synchronously after
20 | the DOM has been updated. You may recall from the `useEffect` exercise, the
21 | [React flow diagram](https://github.com/donavon/hook-flow):
22 |
23 | 
24 |
25 | The `useLayoutEffect` hook runs after the DOM has been updated but before the
26 | browser has had a chance to paint the screen. This means you can make your
27 | measurements and then render the UI with the correct measurements before the
28 | user sees anything.
29 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.problem.ref/README.mdx:
--------------------------------------------------------------------------------
1 | # useImperativeHandle
2 |
3 |
4 |
5 | 👨💼 We've got a new thing for you to work on.
6 |
7 | 🧝♂️ I've put together a `Scrollable` component which is a wrapper around a `div`
8 | that has a way to scroll to the top and bottom of the content. We want to be
9 | able to add buttons to the `App` that will allow users to scroll to the top and
10 | bottom of the content when clicked.
11 |
12 | 👨💼 So we need you to `useImperativeHandle` to expose a `scrollToTop` and
13 | `scrollToBottom` method from the `Scrollable` component. These methods are
14 | already implemented, you just need to expose them.
15 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.problem.ref/index.css:
--------------------------------------------------------------------------------
1 | .messaging-app {
2 | max-width: 350px;
3 | margin: auto;
4 | }
5 |
6 | .messaging-app [role='log'] {
7 | margin: auto;
8 | height: 300px;
9 | overflow-y: scroll;
10 | width: 300px;
11 | outline: 1px solid black;
12 | padding: 30px 10px;
13 | }
14 |
15 | .messaging-app [role='log'] hr {
16 | margin-top: 8px;
17 | margin-bottom: 8px;
18 | }
19 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.problem.ref/messages.tsx:
--------------------------------------------------------------------------------
1 | export type Message = { id: string; author: string; content: string }
2 |
3 | export const allMessages: Array = [
4 | `Leia: Aren't you a little short to be a stormtrooper?`,
5 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`,
6 | `Leia: You're who?`,
7 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`,
8 | `Leia: Ben Kenobi is here! Where is he?`,
9 | `Luke: Come on!`,
10 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`,
11 | `Leia: Put that thing away! You're going to get us all killed.`,
12 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`,
13 | `Leia: It could be worse...`,
14 | `Han: It's worse.`,
15 | `Luke: There's something alive in here!`,
16 | `Han: That's your imagination.`,
17 | `Luke: Something just moves past my leg! Look! Did you see that?`,
18 | `Han: What?`,
19 | `Luke: Help!`,
20 | `Han: Luke! Luke! Luke!`,
21 | `Leia: Luke!`,
22 | `Leia: Luke, Luke, grab a hold of this.`,
23 | `Luke: Blast it, will you! My gun's jammed.`,
24 | `Han: Where?`,
25 | `Luke: Anywhere! Oh!!`,
26 | `Han: Luke! Luke!`,
27 | `Leia: Grab him!`,
28 | `Leia: What happened?`,
29 | `Luke: I don't know, it just let go of me and disappeared...`,
30 | `Han: I've got a very bad feeling about this.`,
31 | `Luke: The walls are moving!`,
32 | `Leia: Don't just stand there. Try to brace it with something.`,
33 | `Luke: Wait a minute!`,
34 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`,
35 | ].map((m, i) => ({
36 | id: String(i),
37 | author: m.split(': ')[0]!,
38 | content: m.split(': ')[1]!,
39 | }))
40 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.solution.ref/README.mdx:
--------------------------------------------------------------------------------
1 | # useImperativeHandle
2 |
3 |
4 |
5 | 👨💼 This is one hook that you don't use often, but you definitely will run into
6 | it at some point so it's a good one to keep in your back pocket.
7 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.solution.ref/index.css:
--------------------------------------------------------------------------------
1 | .messaging-app {
2 | max-width: 350px;
3 | margin: auto;
4 | }
5 |
6 | .messaging-app [role='log'] {
7 | margin: auto;
8 | height: 300px;
9 | overflow-y: scroll;
10 | width: 300px;
11 | outline: 1px solid black;
12 | padding: 30px 10px;
13 | }
14 |
15 | .messaging-app [role='log'] hr {
16 | margin-top: 8px;
17 | margin-bottom: 8px;
18 | }
19 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.solution.ref/index.tsx:
--------------------------------------------------------------------------------
1 | import { useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 | import { allMessages } from './messages'
4 |
5 | type ScrollableImperativeAPI = {
6 | scrollToTop: () => void
7 | scrollToBottom: () => void
8 | }
9 |
10 | function Scrollable({
11 | children,
12 | scrollableRef,
13 | }: { children: React.ReactNode } & {
14 | scrollableRef: React.RefObject
15 | }) {
16 | const containerRef = useRef(null)
17 |
18 | useLayoutEffect(() => {
19 | scrollToBottom()
20 | })
21 |
22 | function scrollToTop() {
23 | if (!containerRef.current) return
24 | containerRef.current.scrollTop = 0
25 | }
26 |
27 | function scrollToBottom() {
28 | if (!containerRef.current) return
29 | containerRef.current.scrollTop = containerRef.current.scrollHeight
30 | }
31 |
32 | useImperativeHandle(scrollableRef, () => ({
33 | scrollToTop,
34 | scrollToBottom,
35 | }))
36 |
37 | return (
38 |
83 | )
84 | }
85 |
86 | const rootEl = document.createElement('div')
87 | document.body.append(rootEl)
88 | ReactDOM.createRoot(rootEl).render()
89 |
90 | /*
91 | eslint
92 | @typescript-eslint/no-unused-vars: "off",
93 | */
94 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.solution.ref/messages.tsx:
--------------------------------------------------------------------------------
1 | export type Message = { id: string; author: string; content: string }
2 |
3 | export const allMessages: Array = [
4 | `Leia: Aren't you a little short to be a stormtrooper?`,
5 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`,
6 | `Leia: You're who?`,
7 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`,
8 | `Leia: Ben Kenobi is here! Where is he?`,
9 | `Luke: Come on!`,
10 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`,
11 | `Leia: Put that thing away! You're going to get us all killed.`,
12 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`,
13 | `Leia: It could be worse...`,
14 | `Han: It's worse.`,
15 | `Luke: There's something alive in here!`,
16 | `Han: That's your imagination.`,
17 | `Luke: Something just moves past my leg! Look! Did you see that?`,
18 | `Han: What?`,
19 | `Luke: Help!`,
20 | `Han: Luke! Luke! Luke!`,
21 | `Leia: Luke!`,
22 | `Leia: Luke, Luke, grab a hold of this.`,
23 | `Luke: Blast it, will you! My gun's jammed.`,
24 | `Han: Where?`,
25 | `Luke: Anywhere! Oh!!`,
26 | `Han: Luke! Luke!`,
27 | `Leia: Grab him!`,
28 | `Leia: What happened?`,
29 | `Luke: I don't know, it just let go of me and disappeared...`,
30 | `Han: I've got a very bad feeling about this.`,
31 | `Luke: The walls are moving!`,
32 | `Leia: Don't just stand there. Try to brace it with something.`,
33 | `Luke: Wait a minute!`,
34 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`,
35 | ].map((m, i) => ({
36 | id: String(i),
37 | author: m.split(': ')[0]!,
38 | content: m.split(': ')[1]!,
39 | }))
40 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/01.solution.ref/scroll.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent, waitFor } = dtl
3 |
4 | import './index.tsx'
5 |
6 | await testStep(
7 | 'Scrollable component handles scroll to top and bottom',
8 | async () => {
9 | // Find the scroll buttons
10 | const scrollTopButton = await screen.findByText(/Scroll to Top/i)
11 | const scrollBottomButton = await screen.findByText(/Scroll to Bottom/i)
12 |
13 | // Find the scrollable container
14 | const scrollableContainer = screen.getByRole('log')
15 |
16 | // Scroll to bottom
17 | fireEvent.click(scrollBottomButton)
18 | await waitFor(() => {
19 | expect(
20 | scrollableContainer.scrollTop,
21 | '🚨 Scrollable container should be scrolled to the bottom when the scroll to bottom button is clicked',
22 | ).toBe(
23 | scrollableContainer.scrollHeight - scrollableContainer.clientHeight,
24 | )
25 | })
26 |
27 | // Scroll to top
28 | fireEvent.click(scrollTopButton)
29 | await waitFor(() => {
30 | expect(
31 | scrollableContainer.scrollTop,
32 | '🚨 Scrollable container should be scrolled to the top when the scroll to top button is clicked',
33 | ).toBe(0)
34 | })
35 | },
36 | )
37 |
--------------------------------------------------------------------------------
/exercises/07.imperative-handle/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Imperative Handles
2 |
3 |
4 |
5 | 👨💼 Hooray! You did it!
6 |
--------------------------------------------------------------------------------
/exercises/08.focus/01.problem.flush-sync/README.mdx:
--------------------------------------------------------------------------------
1 | # flushSync
2 |
3 |
4 |
5 | 🧝♂️ I've put together a new component we need. It's called `` and
6 | it allows users to edit a piece of text inline. We display it in a button and
7 | when the user clicks it, the button turns into a text input. When the user
8 | presses enter, blurs, or hits escape, the text input turns back into a button.
9 |
10 | Right now, when the user clicks the button, the button goes away and is replaced
11 | by the text input, but because their focus was on the button which is now gone,
12 | their focus returns to the `` and the text input is not focused. This is
13 | not a good user experience.
14 |
15 | 👨💼 Thanks Kellie. So now what we need is for you to properly manage focus for
16 | all of these cases.
17 |
18 | - When the user submits the form (by hitting "enter")
19 | - When the user cancels the form (by hitting "escape")
20 | - When the user blurs the input (by tabbing or clicking away)
21 |
22 | Additionally, when the user clicks the button, we want to select all the text so
23 | it's easy for them to edit.
24 |
25 | 🧝♂️ I've added some buttons before and after the input so you have something to
26 | test tab focus with. Good luck!
27 |
28 |
29 | This example uses code from
30 | [trellix](https://github.com/remix-run/example-trellix/blob/3379b3d5e9c0173381031e4f062877e8a3696b2e/app/routes/board.%24id/components.tsx).
31 |
32 |
33 |
34 | 🚨 Because this deals with focus, you'll need to expand the test and then run
35 | it for it to pass.
36 |
37 |
--------------------------------------------------------------------------------
/exercises/08.focus/01.problem.flush-sync/index.css:
--------------------------------------------------------------------------------
1 | main {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 1rem;
5 | padding: 3rem;
6 | }
7 | .editable-text {
8 | button {
9 | /* remove button styles. Make it look like text */
10 | background: none;
11 | border: none;
12 | padding: 4px 8px;
13 | font-size: 1.5rem;
14 | font-weight: bold;
15 | }
16 |
17 | input {
18 | /* make it the same size as the button */
19 | font-size: 1.5rem;
20 | font-weight: bold;
21 | padding: 4px 8px;
22 | border: none;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/exercises/08.focus/01.problem.flush-sync/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | function EditableText({
5 | id,
6 | initialValue = '',
7 | fieldName,
8 | inputLabel,
9 | buttonLabel,
10 | }: {
11 | id?: string
12 | initialValue?: string
13 | fieldName: string
14 | inputLabel: string
15 | buttonLabel: string
16 | }) {
17 | const [edit, setEdit] = useState(false)
18 | const [value, setValue] = useState(initialValue)
19 | const inputRef = useRef(null)
20 | // 🐨 add a button ref here
21 |
22 | return edit ? (
23 |
57 | ) : (
58 |
70 | )
71 | }
72 |
73 | function App() {
74 | return (
75 |
76 |
77 |
89 |
90 |
91 | )
92 | }
93 |
94 | const rootEl = document.createElement('div')
95 | document.body.append(rootEl)
96 | ReactDOM.createRoot(rootEl).render()
97 |
--------------------------------------------------------------------------------
/exercises/08.focus/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Focus Management
2 |
3 |
4 |
5 | 👨💼 It's important to consider users of keyboards, both those with disabilities
6 | who rely on them as well as power users who prefer to use the keyboard over the
7 | mouse. Good work!
8 |
--------------------------------------------------------------------------------
/exercises/08.focus/README.mdx:
--------------------------------------------------------------------------------
1 | # Focus Management
2 |
3 |
4 |
5 | Helping the user's focus stay on the right place is a key part of the user
6 | experience. This is especially important for users who rely on screen readers or
7 | keyboard navigation. But even able users can benefit from a well-thought focus
8 | management experience.
9 |
10 | Sometimes, the element you want to focus on only becomes available after a state
11 | update. For example:
12 |
13 | ```tsx
14 | function MyComponent() {
15 | const [show, setShow] = useState(false)
16 |
17 | return (
18 |
19 |
20 | {show ? : null}
21 |
22 | )
23 | }
24 | ```
25 |
26 | Presumably after the user clicks "show" they will want to type something in the
27 | input there. Good focus management would focus the input after it becomes
28 | visible.
29 |
30 | It's important for you to know that in React state updates happen in batches.
31 | So state updates do not necessarily take place at the same time you
32 | call the state updater function.
33 |
34 | As a result of React state update batching, if you try to focus an element right
35 | after a state update, it might not work as expected. This is because the element
36 | you want to focus on might not be available yet.
37 |
38 | ```tsx remove=10
39 | function MyComponent() {
40 | const inputRef = useRef(null)
41 | const [show, setShow] = useState(false)
42 |
43 | return (
44 |
45 |
53 | {show ? : null}
54 |
55 | )
56 | }
57 | ```
58 |
59 | The solution to this problem is to force React to run the state and DOM updates
60 | synchronously so that the element you want to focus on is available when you try
61 | to focus it.
62 |
63 | You do this by using the `flushSync` function from the `react-dom` package.
64 |
65 | ```tsx
66 | import { flushSync } from 'react-dom'
67 |
68 | function MyComponent() {
69 | const inputRef = useRef(null)
70 | const [show, setShow] = useState(false)
71 |
72 | return (
73 |
74 |
84 | {show ? : null}
85 |
86 | )
87 | }
88 | ```
89 |
90 | What `flushSync` does is that it forces React to run the state update and DOM
91 | update synchronously. This way, the input element will be available when you try
92 | to focus it on the line following the `flushSync` call.
93 |
94 | In general you want to avoid this de-optimization, but in some cases (like focus
95 | management), it's the perfect solution.
96 |
97 | Learn more in [📜 the `flushSync` docs](https://react.dev/reference/react-dom/flushSync).
98 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/01.problem.sub/README.mdx:
--------------------------------------------------------------------------------
1 | # useSyncExternalStore
2 |
3 |
4 |
5 | 🦉 When you have a design that needs to be responsive, you use
6 | [media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries)
7 | to change the layout of the page based on the size of the screen. Media queries
8 | can tell you a lot more than just the width of the page and sometimes you need
9 | to know whether a media query matches even outside of a CSS context.
10 |
11 | The browser supports a JavaScript API called `matchMedia` that allows you to
12 | query the current state of a media query:
13 |
14 | ```tsx
15 | const prefersDarkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
16 | console.log(prefersDarkModeQuery.matches) // true if the user prefers dark mode
17 | ```
18 |
19 | 👨💼 Thanks for that Olivia. So yes, our users want a component that displays
20 | whether they're on a narrow screen. We're going to build this into a more
21 | generic hook that will allow us to determine any media query's match and also
22 | keep the state in sync with the media query. And you're going to need to use
23 | `useSyncExternalStore` to do it.
24 |
25 | Go ahead and follow the emoji instructions. You'll know you got it right when
26 | you resize your screen and the text changes.
27 |
28 |
29 | 🦉 If we really were just trying to display some different text based on the
30 | screen size, we could use CSS media queries and not have to write any
31 | JavaScript at all. But sometimes we need to know the state of a media query in
32 | JavaScript for more complex interactions, so we're going to use a simple
33 | example to demonstrate how to do this to handle those cases and we'll be using
34 | `useSyncExternalStore` for that.
35 |
36 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/01.problem.sub/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 |
3 | // 💰 this is the mediaQuery we're going to be matching against:
4 | // const mediaQuery = '(max-width: 600px)'
5 |
6 | // 🐨 make a getSnapshot function here that returns whether the media query matches
7 |
8 | // 🐨 make a subscribe function here which takes a callback function
9 | // 🐨 create a matchQueryList variable here with the mediaQuery from above (📜 https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList)
10 | // 🐨 add a change listener to the mediaQueryList which calls the callback
11 | // 🐨 return a cleanup function which removes the change event listener for the callback
12 |
13 | function NarrowScreenNotifier() {
14 | // 🐨 assign this to useSyncExternalStore with the subscribe and getSnapshot functions above
15 | const isNarrow = false
16 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
17 | }
18 |
19 | function App() {
20 | return
21 | }
22 |
23 | const rootEl = document.createElement('div')
24 | document.body.append(rootEl)
25 | const root = ReactDOM.createRoot(rootEl)
26 | root.render()
27 |
28 | // @ts-expect-error 🚨 this is for the test
29 | window.__epicReactRoot = root
30 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/01.solution.sub/README.mdx:
--------------------------------------------------------------------------------
1 | # useSyncExternalStore
2 |
3 |
4 |
5 | 👨💼 Great work! Our users will now know whether they're on a narrow screen 🤡
6 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/01.solution.sub/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | const mediaQuery = '(max-width: 600px)'
5 | function getSnapshot() {
6 | return window.matchMedia(mediaQuery).matches
7 | }
8 |
9 | function subscribe(callback: () => void) {
10 | const mediaQueryList = window.matchMedia(mediaQuery)
11 | mediaQueryList.addEventListener('change', callback)
12 | return () => {
13 | mediaQueryList.removeEventListener('change', callback)
14 | }
15 | }
16 |
17 | function NarrowScreenNotifier() {
18 | const isNarrow = useSyncExternalStore(subscribe, getSnapshot)
19 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
20 | }
21 |
22 | function App() {
23 | return
24 | }
25 |
26 | const rootEl = document.createElement('div')
27 | document.body.append(rootEl)
28 | const root = ReactDOM.createRoot(rootEl)
29 | root.render()
30 |
31 | // @ts-expect-error 🚨 this is for the test
32 | window.__epicReactRoot = root
33 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/01.solution.sub/sync-media-query.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen } = dtl
3 |
4 | import './index.tsx'
5 |
6 | let mediaQueryCallbacks: Array<(e: { matches: boolean }) => void> = []
7 | let currentMatches = false
8 |
9 | const originalMatchMedia = window.matchMedia
10 | // @ts-expect-error - meh it's free javascript
11 | window.matchMedia = (query: string) => ({
12 | ...originalMatchMedia(query),
13 | matches: currentMatches,
14 | media: query,
15 | addEventListener: (
16 | event: string,
17 | callback: (e: { matches: boolean }) => void,
18 | ) => {
19 | mediaQueryCallbacks.push(callback)
20 | },
21 | removeEventListener: (
22 | event: string,
23 | callback: (e: { matches: boolean }) => void,
24 | ) => {
25 | mediaQueryCallbacks = mediaQueryCallbacks.filter((cb) => cb !== callback)
26 | },
27 | })
28 |
29 | function triggerMediaQueryChange(matches: boolean) {
30 | currentMatches = matches
31 | mediaQueryCallbacks.forEach((callback) => callback({ matches }))
32 | }
33 |
34 | await testStep(
35 | 'NarrowScreenNotifier renders wide screen message initially',
36 | async () => {
37 | const message = await screen.findByText('You are on a wide screen')
38 | expect(message).toBeTruthy()
39 | },
40 | )
41 |
42 | await testStep(
43 | 'NarrowScreenNotifier updates when media query changes to narrow',
44 | async () => {
45 | triggerMediaQueryChange(true)
46 | const message = await screen.findByText('You are on a narrow screen')
47 | expect(message).toBeTruthy()
48 | },
49 | )
50 |
51 | await testStep(
52 | 'NarrowScreenNotifier updates when media query changes back to wide',
53 | async () => {
54 | triggerMediaQueryChange(false)
55 | const message = await screen.findByText('You are on a wide screen')
56 | expect(message).toBeTruthy()
57 | },
58 | )
59 |
60 | await testStep(
61 | 'NarrowScreenNotifier removes event listener on unmount',
62 | async () => {
63 | const initialCallbackCount = mediaQueryCallbacks.length
64 | // @ts-expect-error 🚨 this is for the test
65 | window.__epicReactRoot.unmount()
66 | expect(mediaQueryCallbacks.length).toBe(initialCallbackCount - 1)
67 | },
68 | )
69 |
70 | // Cleanup
71 | window.matchMedia = originalMatchMedia
72 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/02.problem.util/README.mdx:
--------------------------------------------------------------------------------
1 | # Make Store Utility
2 |
3 |
4 |
5 | 👨💼 We want to make this utility generally useful so we can use it for any media
6 | query. So please stick most of our logic in a `makeMediaQueryStore` function
7 | and have that return a custom hook people can use to keep track of the current
8 | media query's matching state.
9 |
10 | It'll be something like this:
11 |
12 | ```tsx
13 | export function makeMediaQueryStore(mediaQuery: string) {
14 | // ...
15 | }
16 |
17 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
18 |
19 | function App() {
20 | const isNarrow = useNarrowMediaQuery()
21 | // ...
22 | }
23 | ```
24 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/02.problem.util/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | const mediaQuery = '(max-width: 600px)'
5 |
6 | // 🐨 put getSnapshot and subscribe in a new function called makeMediaQueryStore
7 | // which accepts a mediaQuery and returns a hook that uses useSyncExternalStore
8 | // with the subscribe and getSnapshot functions.
9 | function getSnapshot() {
10 | return window.matchMedia(mediaQuery).matches
11 | }
12 |
13 | function subscribe(callback: () => void) {
14 | const mediaQueryList = window.matchMedia(mediaQuery)
15 | mediaQueryList.addEventListener('change', callback)
16 | return () => {
17 | mediaQueryList.removeEventListener('change', callback)
18 | }
19 | }
20 | // 🐨 put everything above in the makeMediaQueryStore function
21 |
22 | // 🐨 call makeMediaQueryStore with '(max-width: 600px)' and assign the return
23 | // value to a variable called useNarrowMediaQuery
24 |
25 | function NarrowScreenNotifier() {
26 | // 🐨 call useNarrowMediaQuery here instead of useSyncExternalStore
27 | const isNarrow = useSyncExternalStore(subscribe, getSnapshot)
28 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
29 | }
30 |
31 | function App() {
32 | return
33 | }
34 |
35 | const rootEl = document.createElement('div')
36 | document.body.append(rootEl)
37 | const root = ReactDOM.createRoot(rootEl)
38 | root.render()
39 |
40 | // @ts-expect-error 🚨 this is for the test
41 | window.__epicReactRoot = root
42 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/02.solution.util/README.mdx:
--------------------------------------------------------------------------------
1 | # Make Store Utility
2 |
3 |
4 |
5 | 👨💼 Great! With that we now have a reusable utility and can use this to subscribe
6 | to any media query!
7 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/02.solution.util/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | export function makeMediaQueryStore(mediaQuery: string) {
5 | function getSnapshot() {
6 | return window.matchMedia(mediaQuery).matches
7 | }
8 |
9 | function subscribe(callback: () => void) {
10 | const mediaQueryList = window.matchMedia(mediaQuery)
11 | mediaQueryList.addEventListener('change', callback)
12 | return () => {
13 | mediaQueryList.removeEventListener('change', callback)
14 | }
15 | }
16 |
17 | return function useMediaQuery() {
18 | return useSyncExternalStore(subscribe, getSnapshot)
19 | }
20 | }
21 |
22 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
23 |
24 | function NarrowScreenNotifier() {
25 | const isNarrow = useNarrowMediaQuery()
26 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
27 | }
28 |
29 | function App() {
30 | return
31 | }
32 |
33 | const rootEl = document.createElement('div')
34 | document.body.append(rootEl)
35 | const root = ReactDOM.createRoot(rootEl)
36 | root.render()
37 |
38 | // @ts-expect-error 🚨 this is for the test
39 | window.__epicReactRoot = root
40 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/02.solution.util/sync-media-query.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen } = dtl
3 |
4 | import './index.tsx'
5 |
6 | let mediaQueryCallbacks: Array<(e: { matches: boolean }) => void> = []
7 | let currentMatches = false
8 |
9 | const originalMatchMedia = window.matchMedia
10 | // @ts-expect-error - meh it's free javascript
11 | window.matchMedia = (query: string) => ({
12 | ...originalMatchMedia(query),
13 | matches: currentMatches,
14 | media: query,
15 | addEventListener: (
16 | event: string,
17 | callback: (e: { matches: boolean }) => void,
18 | ) => {
19 | mediaQueryCallbacks.push(callback)
20 | },
21 | removeEventListener: (
22 | event: string,
23 | callback: (e: { matches: boolean }) => void,
24 | ) => {
25 | mediaQueryCallbacks = mediaQueryCallbacks.filter((cb) => cb !== callback)
26 | },
27 | })
28 |
29 | function triggerMediaQueryChange(matches: boolean) {
30 | currentMatches = matches
31 | mediaQueryCallbacks.forEach((callback) => callback({ matches }))
32 | }
33 |
34 | await testStep(
35 | 'NarrowScreenNotifier renders wide screen message initially',
36 | async () => {
37 | const message = await screen.findByText('You are on a wide screen')
38 | expect(message).toBeTruthy()
39 | },
40 | )
41 |
42 | await testStep(
43 | 'NarrowScreenNotifier updates when media query changes to narrow',
44 | async () => {
45 | triggerMediaQueryChange(true)
46 | const message = await screen.findByText('You are on a narrow screen')
47 | expect(message).toBeTruthy()
48 | },
49 | )
50 |
51 | await testStep(
52 | 'NarrowScreenNotifier updates when media query changes back to wide',
53 | async () => {
54 | triggerMediaQueryChange(false)
55 | const message = await screen.findByText('You are on a wide screen')
56 | expect(message).toBeTruthy()
57 | },
58 | )
59 |
60 | await testStep(
61 | 'NarrowScreenNotifier removes event listener on unmount',
62 | async () => {
63 | const initialCallbackCount = mediaQueryCallbacks.length
64 | // @ts-expect-error 🚨 this is for the test
65 | window.__epicReactRoot.unmount()
66 | expect(mediaQueryCallbacks.length).toBe(initialCallbackCount - 1)
67 | },
68 | )
69 |
70 | // Cleanup
71 | window.matchMedia = originalMatchMedia
72 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/03.problem.ssr/README.mdx:
--------------------------------------------------------------------------------
1 | # Handling Server Rendering
2 |
3 |
4 |
5 | 👨💼 We don't currently do any server rendering, but in the future we may want to
6 | and this requires some special handling with `useSyncExternalStore`.
7 |
8 | 🧝♂️ I've simulated a server rendering environment by
9 | adding some code to the bottom of our file. First, we render the `` to a
10 | string, then we set that to the `innerHTML` of our `rootEl`. Then we call
11 | `hydrateRoot` to rehydrate our application.
12 |
13 | ```tsx
14 | const rootEl = document.createElement('div')
15 | document.body.append(rootEl)
16 | // simulate server rendering
17 | rootEl.innerHTML = (await import('react-dom/server')).renderToString()
18 |
19 | // simulate taking a while for the JS to load...
20 | await new Promise((resolve) => setTimeout(resolve, 1000))
21 |
22 | ReactDOM.hydrateRoot(rootEl, )
23 | ```
24 |
25 | 👨💼 This is a bit of a hack, but it's a good way to simulate server rendering
26 | and ensure that our application works in a server rendering situation.
27 |
28 | Because the server won't know whether a media query matches, we can't use the
29 | `getServerSnapshot()` argument of `useSyncExternalStore`. Instead, we'll leave
30 | that argument off, and wrap our `` in a
31 | [``](https://react.dev/reference/react/Suspense) component with a
32 | fallback of `""` (we won't show anything until the client hydrates).
33 |
34 | With this, you'll notice there's an error in the console. Nothing's technically
35 | wrong, but React logs this in this situation (I honestly personally disagree
36 | that they should do this, but 🤷♂️). So as extra credit, you can add an
37 | `onRecoverableError` function to the `hydrateRoot` call and if the given error
38 | includes the string `'Missing getServerSnapshot'` then you can return,
39 | otherwise, log the error.
40 |
41 | Good luck!
42 |
43 | ```tsx
44 | import { hydrateRoot } from 'react-dom/client'
45 |
46 | const root = hydrateRoot(document.getElementById('root'), , {
47 | onRecoverableError: (error, errorInfo) => {
48 | console.error('Caught error', error, error.cause, errorInfo.componentStack)
49 | },
50 | })
51 | ```
52 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/03.problem.ssr/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | export function makeMediaQueryStore(mediaQuery: string) {
5 | function getSnapshot() {
6 | return window.matchMedia(mediaQuery).matches
7 | }
8 |
9 | function subscribe(callback: () => void) {
10 | const mediaQueryList = window.matchMedia(mediaQuery)
11 | mediaQueryList.addEventListener('change', callback)
12 | return () => {
13 | mediaQueryList.removeEventListener('change', callback)
14 | }
15 | }
16 |
17 | return function useMediaQuery() {
18 | return useSyncExternalStore(subscribe, getSnapshot)
19 | }
20 | }
21 |
22 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
23 |
24 | function NarrowScreenNotifier() {
25 | const isNarrow = useNarrowMediaQuery()
26 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
27 | }
28 |
29 | function App() {
30 | return (
31 |
32 |
This is your narrow screen state:
33 | {/* 🐨 wrap this in a Suspense component around this with a fallback prop of "" */}
34 | {/* 📜 https://react.dev/reference/react/Suspense */}
35 |
36 |
37 | )
38 | }
39 |
40 | const rootEl = document.createElement('div')
41 | document.body.append(rootEl)
42 | // 🦉 here's how we pretend we're server-rendering
43 | rootEl.innerHTML = (await import('react-dom/server')).renderToString()
44 |
45 | // 🦉 here's how we simulate a delay in hydrating with client-side js
46 | await new Promise((resolve) => setTimeout(resolve, 1000))
47 |
48 | const root = ReactDOM.hydrateRoot(rootEl, , {
49 | // 💯 if you want to silence the error add a onRecoverableError function here
50 | // and if the error includes 'Missing getServerSnapshot' then return early
51 | // otherwise log the error so you don't miss any other errors.
52 | })
53 |
54 | // @ts-expect-error 🚨 this is for the test
55 | window.__epicReactRoot = root
56 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/03.solution.ssr/README.mdx:
--------------------------------------------------------------------------------
1 | # Handling Server Rendering
2 |
3 |
4 |
5 | 👨💼 Great work! You now know how to properly handle server rendering of something
6 | we don't know until the client-render when it comes to an external store like
7 | this.
8 |
9 | 🦉 There are more things you can do for different cases (like the user's
10 | light/dark mode preference) to offer a better user experience. Check out
11 | [`@epic-web/client-hints`](https://www.npmjs.com/package/@epic-web/client-hints)
12 | to see how you can handle this even better if you're interested.
13 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/03.solution.ssr/index.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, useSyncExternalStore } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | export function makeMediaQueryStore(mediaQuery: string) {
5 | function getSnapshot() {
6 | return window.matchMedia(mediaQuery).matches
7 | }
8 |
9 | function subscribe(callback: () => void) {
10 | const mediaQueryList = window.matchMedia(mediaQuery)
11 | mediaQueryList.addEventListener('change', callback)
12 | return () => {
13 | mediaQueryList.removeEventListener('change', callback)
14 | }
15 | }
16 |
17 | return function useMediaQuery() {
18 | return useSyncExternalStore(subscribe, getSnapshot)
19 | }
20 | }
21 |
22 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
23 |
24 | function NarrowScreenNotifier() {
25 | const isNarrow = useNarrowMediaQuery()
26 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
27 | }
28 |
29 | function App() {
30 | return (
31 |
32 |
This is your narrow screen state:
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | const rootEl = document.createElement('div')
41 | document.body.append(rootEl)
42 | // 🦉 here's how we pretend we're server-rendering
43 | rootEl.innerHTML = (await import('react-dom/server')).renderToString()
44 |
45 | // 🦉 here's how we simulate a delay in hydrating with client-side js
46 | await new Promise((resolve) => setTimeout(resolve, 1000))
47 |
48 | const root = ReactDOM.hydrateRoot(rootEl, , {
49 | onRecoverableError(error) {
50 | if (String(error).includes('Missing getServerSnapshot')) return
51 |
52 | console.error(error)
53 | },
54 | })
55 |
56 | // @ts-expect-error 🚨 this is for the test
57 | window.__epicReactRoot = root
58 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/03.solution.ssr/sync-media-query.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen } = dtl
3 |
4 | import './index.tsx'
5 |
6 | let mediaQueryCallbacks: Array<(e: { matches: boolean }) => void> = []
7 | let currentMatches = false
8 |
9 | const originalMatchMedia = window.matchMedia
10 | // @ts-expect-error - meh it's free javascript
11 | window.matchMedia = (query: string) => ({
12 | ...originalMatchMedia(query),
13 | matches: currentMatches,
14 | media: query,
15 | addEventListener: (
16 | event: string,
17 | callback: (e: { matches: boolean }) => void,
18 | ) => {
19 | mediaQueryCallbacks.push(callback)
20 | },
21 | removeEventListener: (
22 | event: string,
23 | callback: (e: { matches: boolean }) => void,
24 | ) => {
25 | mediaQueryCallbacks = mediaQueryCallbacks.filter((cb) => cb !== callback)
26 | },
27 | })
28 |
29 | function triggerMediaQueryChange(matches: boolean) {
30 | currentMatches = matches
31 | mediaQueryCallbacks.forEach((callback) => callback({ matches }))
32 | }
33 |
34 | await testStep(
35 | 'NarrowScreenNotifier renders wide screen message initially',
36 | async () => {
37 | const message = await screen.findByText('You are on a wide screen')
38 | expect(message).toBeTruthy()
39 | },
40 | )
41 |
42 | await testStep(
43 | 'NarrowScreenNotifier updates when media query changes to narrow',
44 | async () => {
45 | triggerMediaQueryChange(true)
46 | const message = await screen.findByText('You are on a narrow screen')
47 | expect(message).toBeTruthy()
48 | },
49 | )
50 |
51 | await testStep(
52 | 'NarrowScreenNotifier updates when media query changes back to wide',
53 | async () => {
54 | triggerMediaQueryChange(false)
55 | const message = await screen.findByText('You are on a wide screen')
56 | expect(message).toBeTruthy()
57 | },
58 | )
59 |
60 | await testStep(
61 | 'NarrowScreenNotifier removes event listener on unmount',
62 | async () => {
63 | const initialCallbackCount = mediaQueryCallbacks.length
64 | // @ts-expect-error 🚨 this is for the test
65 | window.__epicReactRoot.unmount()
66 | expect(mediaQueryCallbacks.length).toBe(initialCallbackCount - 1)
67 | },
68 | )
69 |
70 | // Cleanup
71 | window.matchMedia = originalMatchMedia
72 |
--------------------------------------------------------------------------------
/exercises/09.sync-external/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Sync External State
2 |
3 |
4 |
5 | 👨💼 Great work! You now know how to integrate React with external bits of
6 | changing state. Well done!
7 |
--------------------------------------------------------------------------------
/exercises/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Advanced React APIs 🔥
2 |
3 |
4 |
5 | 👨💼 Congratulations! You've finished the workshop!
6 |
7 | 🦉 There are a few hooks that we haven't covered in this workshop that we won't
8 | be covering in Epic React because they're extremely rarely used in the wild. Feel
9 | free to read up on them here:
10 |
11 | - [`useDebugValue`](https://react.dev/reference/react/useDebugValue)
12 | - [`useInsertionEffect`](https://react.dev/reference/react/useInsertionEffect)
13 |
--------------------------------------------------------------------------------
/exercises/README.mdx:
--------------------------------------------------------------------------------
1 | # Advanced React APIs 🔥
2 |
3 |
4 |
5 | 👨💼 Hello there! I'm Peter the Product Manager and I'll be helping guide you
6 | through all the things that our users want to see in our app that you'll be
7 | working on in this workshop.
8 |
9 | We're going to cover a lot of ground and a handful of components that need to be
10 | enhanced for the features our users are looking for. You'll be building things
11 | using React hooks like `useReducer`, `use`, `useLayoutEffect`,
12 | `useSyncExternalStore`, and more. You'll even be building your own custom
13 | hooks!
14 |
15 | In addition to advanced hooks, we'll also be covering a couple advanced use
16 | cases like focus management with `flushSync` as well as `createPortal`.
17 |
18 | It's going to be a full experience, so let's get started!
19 |
20 |
21 | 🚨 Note because we're refactoring each step rather than changing behavior, the
22 | tests will all be working from the start. So the tests are just there to help
23 | you make sure you have not regressed any functionality in the course of your
24 | refactoring.
25 |
26 |
27 | 🎵 Check out the workshop theme song! 🎶
28 |
29 |
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "advanced-react-apis",
3 | "private": true,
4 | "epicshop": {
5 | "title": "Advanced React APIs 🔥",
6 | "subtitle": "Learn the more advanced React APIs and different use cases to enable great user experiences.",
7 | "githubRepo": "https://github.com/epicweb-dev/advanced-react-apis",
8 | "stackBlitzConfig": {
9 | "view": "editor"
10 | },
11 | "product": {
12 | "host": "www.epicreact.dev",
13 | "slug": "advanced-react-apis",
14 | "displayName": "EpicReact.dev",
15 | "displayNameShort": "Epic React",
16 | "logo": "/logo.svg",
17 | "discordChannelId": "1285244676286189569",
18 | "discordTags": [
19 | "1285246046498328627",
20 | "1285245763428810815"
21 | ]
22 | },
23 | "onboardingVideo": "https://www.epicweb.dev/tips/get-started-with-the-epic-workshop-app-for-react",
24 | "instructor": {
25 | "name": "Kent C. Dodds",
26 | "avatar": "/images/instructor.png",
27 | "𝕏": "kentcdodds"
28 | }
29 | },
30 | "type": "module",
31 | "imports": {
32 | "#*": "./*"
33 | },
34 | "prettier": "@epic-web/config/prettier",
35 | "scripts": {
36 | "postinstall": "cd ./epicshop && npm install",
37 | "start": "npx --prefix ./epicshop epicshop start",
38 | "dev": "npx --prefix ./epicshop epicshop start",
39 | "setup": "node ./epicshop/setup.js",
40 | "setup:custom": "node ./epicshop/setup-custom.js",
41 | "lint": "eslint .",
42 | "format": "prettier --write .",
43 | "typecheck": "tsc -b"
44 | },
45 | "keywords": [],
46 | "author": "Kent C. Dodds (https://kentcdodds.com/)",
47 | "license": "GPL-3.0-only",
48 | "dependencies": {
49 | "react": "19.0.0",
50 | "react-dom": "19.0.0"
51 | },
52 | "devDependencies": {
53 | "@epic-web/config": "^1.16.3",
54 | "@epic-web/workshop-utils": "^5.20.1",
55 | "@types/react": "^19.0.0",
56 | "@types/react-dom": "^19.0.0",
57 | "eslint": "^9.16.0",
58 | "npm-run-all": "^4.1.5",
59 | "prettier": "^3.4.2",
60 | "typescript": "^5.7.2"
61 | },
62 | "engines": {
63 | "node": ">=20",
64 | "npm": ">=9.3.0",
65 | "git": ">=2.18.0"
66 | },
67 | "prettierIgnore": [
68 | "node_modules",
69 | "**/build/**",
70 | "**/public/build/**",
71 | ".env",
72 | "**/package.json",
73 | "**/tsconfig.json",
74 | "**/package-lock.json",
75 | "**/playwright-report/**"
76 | ]
77 | }
78 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/hook-flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/hook-flow.png
--------------------------------------------------------------------------------
/public/images/instructor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/images/instructor.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
26 |
--------------------------------------------------------------------------------
/public/og/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/og/background.png
--------------------------------------------------------------------------------
/reset.d.ts:
--------------------------------------------------------------------------------
1 | import '@epic-web/config/reset.d.ts'
2 |
--------------------------------------------------------------------------------
/shared/tic-tac-toe-utils.tsx:
--------------------------------------------------------------------------------
1 | export type Player = 'X' | 'O'
2 | export type Squares = Array
3 |
4 | export type GameState = {
5 | history: Array
6 | currentStep: number
7 | }
8 |
9 | function isSquare(value: unknown): value is null | 'X' | 'O' {
10 | return value === null || value === 'X' || value === 'O'
11 | }
12 |
13 | function isArray(value: unknown): value is Array {
14 | return Array.isArray(value)
15 | }
16 |
17 | function isSquaresArray(value: unknown): value is Squares {
18 | if (!isArray(value)) return false
19 | return value.length === 9 && value.every(isSquare)
20 | }
21 |
22 | function isHistory(value: unknown): value is Array {
23 | if (!isArray(value)) return false
24 | if (!value.every(isSquaresArray)) return false
25 | return true
26 | }
27 |
28 | function isStep(value: unknown): value is number {
29 | return (
30 | typeof value === 'number' &&
31 | Number.isInteger(value) &&
32 | value >= 0 &&
33 | value <= 9
34 | )
35 | }
36 |
37 | export function isValidGameState(value: unknown): value is GameState {
38 | return (
39 | typeof value === 'object' &&
40 | value !== null &&
41 | isHistory((value as any).history) &&
42 | isStep((value as any).currentStep)
43 | )
44 | }
45 |
46 | export function calculateStatus(
47 | winner: null | string,
48 | squares: Squares,
49 | nextValue: Player,
50 | ) {
51 | return winner
52 | ? `Winner: ${winner}`
53 | : squares.every(Boolean)
54 | ? `Scratch: Cat's game`
55 | : `Next player: ${nextValue}`
56 | }
57 |
58 | export function calculateNextValue(squares: Squares): Player {
59 | const xSquaresCount = squares.filter((r) => r === 'X').length
60 | const oSquaresCount = squares.filter((r) => r === 'O').length
61 | return oSquaresCount === xSquaresCount ? 'X' : 'O'
62 | }
63 |
64 | export function calculateWinner(squares: Squares): Player | null {
65 | const lines = [
66 | [0, 1, 2],
67 | [3, 4, 5],
68 | [6, 7, 8],
69 | [0, 3, 6],
70 | [1, 4, 7],
71 | [2, 5, 8],
72 | [0, 4, 8],
73 | [2, 4, 6],
74 | ]
75 | for (let i = 0; i < lines.length; i++) {
76 | const line = lines[i]
77 | if (!line) continue
78 | const [a, b, c] = line
79 | if (a === undefined || b === undefined || c === undefined) continue
80 |
81 | const player = squares[a]
82 | if (player && player === squares[b] && player === squares[c]) {
83 | return player
84 | }
85 | }
86 | return null
87 | }
88 |
--------------------------------------------------------------------------------
/shared/utils.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Sets the search parameters of the current URL.
3 | *
4 | * @param {Record} params - The search parameters to set.
5 | * @param {Object} options - Additional options for setting the search parameters.
6 | * @param {boolean} options.replace - Whether to replace the current URL in the history or not.
7 | * @returns {URLSearchParams} - The updated search parameters.
8 | */
9 | export function setGlobalSearchParams(
10 | params: Record,
11 | options: { replace?: boolean } = {},
12 | ) {
13 | const searchParams = new URLSearchParams(window.location.search)
14 | for (const [key, value] of Object.entries(params)) {
15 | if (!value) searchParams.delete(key)
16 | else searchParams.set(key, value)
17 | }
18 | const newUrl = [window.location.pathname, searchParams.toString()]
19 | .filter(Boolean)
20 | .join('?')
21 | if (options.replace) {
22 | window.history.replaceState({}, '', newUrl)
23 | } else {
24 | window.history.pushState({}, '', newUrl)
25 | }
26 | return searchParams
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx"],
3 | "extends": ["@epic-web/config/typescript"],
4 | "compilerOptions": {
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | // makes it a bit easier for workshop participants
7 | "noUncheckedIndexedAccess": false,
8 | "paths": {
9 | "#*": ["./*"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------