Skip to content

Commit

Permalink
finish exercise 9
Browse files Browse the repository at this point in the history
  • Loading branch information
kentcdodds committed Mar 9, 2024
1 parent 0165794 commit 477449c
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 0 deletions.
32 changes: 32 additions & 0 deletions exercises/09.sync-external/01.problem.sub/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,33 @@
# useSyncExternalStore

🦉 When you have a design that needs to be responsive, you use
[media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries)
to change the layout of the page based on the size of the screen. Media queries
can tell you a lot more than just the width of the page and sometimes you need
to know whether a media query matches even outside of a CSS context.

The browser supports a JavaScript API called `matchMedia` that allows you to
query the current state of a media query:

```tsx
const prefersDarkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
console.log(prefersDarkModeQuery.matches) // true if the user prefers dark mode
```

👨‍💼 Thanks for that Olivia. So yes, our users want a component that displays
whether they're on a narrow screen. We're going to build this into a more
generic hook that will allow us to determine any media query's match and also
keep the state in sync with the media query. And you're going to need to use
`useSyncExternalStore` to do it.

Go ahead and follow the emoji instructions. You'll know you got it right when
you resize your screen and the text changes.

<callout-info class="aside">
🦉 If we really were just trying to display some different text based on the
screen size, we could use CSS media queries and not have to write any
JavaScript at all. But sometimes we need to know the state of a media query in
JavaScript for more complex interactions, so we're going to use a simple
example to demonstrate how to do this to handle those cases and we'll be using
`useSyncExternalStore` for that.
</callout-info>
11 changes: 11 additions & 0 deletions exercises/09.sync-external/01.problem.sub/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import * as ReactDOM from 'react-dom/client'

// 💰 this is the mediaQuery we're going to be matching against:
// const mediaQuery = '(max-width: 600px)'

// 🐨 make a getSnapshot function here that returns whether the media query matches

// 🐨 make a subscribe function here which takes a callback function
// 🐨 create a matchQueryList variable here with the mediaQuery from above (📜 https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList)
// 🐨 add a change listener to the mediaQueryList which calls the callback
// 🐨 return a cleanup function whihc removes the change event listener for the callback

function NarrowScreenNotifier() {
// 🐨 assign this to useSyncExternalStore with the subscribe and getSnapshot functions above
const isNarrow = false
return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
}
Expand Down
2 changes: 2 additions & 0 deletions exercises/09.sync-external/01.solution.sub/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# useSyncExternalStore

👨‍💼 Great work! Our users will now know whether they're on a narrow screen 🤡
5 changes: 5 additions & 0 deletions exercises/09.sync-external/02.problem.util/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
# Make Store Utility

👨‍💼 We want to make this utility generally useful so we can use it for any media
query. So please stick most of our logic in a `makeMediaQueryStore` function
and have that return a custom hook people can use to keep track of the current
media query's matching state.
9 changes: 9 additions & 0 deletions exercises/09.sync-external/02.problem.util/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { useSyncExternalStore } from 'react'
import * as ReactDOM from 'react-dom/client'

const mediaQuery = '(max-width: 600px)'

// 🐨 put getSnapshot and subscribe in a new function called makeMediaQueryStore
// which accepts a mediaQuery and returns a hook that uses useSyncExternalStore
// with the subscribe and getSnapshot functions.
function getSnapshot() {
return window.matchMedia(mediaQuery).matches
}
Expand All @@ -13,8 +17,13 @@ function subscribe(callback: () => void) {
mediaQueryList.removeEventListener('change', callback)
}
}
// 🐨 put everything above in the makeMediaQueryStore function

// 🐨 call makeMediaQueryStore with '(max-width: 600px)' and assign the return
// value to a variable called useNarrowMediaQuery

function NarrowScreenNotifier() {
// 🐨 call useNarrowMediaQuery here instead of useSyncExternalStore
const isNarrow = useSyncExternalStore(subscribe, getSnapshot)
return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
}
Expand Down
3 changes: 3 additions & 0 deletions exercises/09.sync-external/02.solution.util/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Make Store Utility

👨‍💼 Great! With that we now have a reusable utility and can use this to subscribe
to any media query!
26 changes: 26 additions & 0 deletions exercises/09.sync-external/03.problem.ssr/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,27 @@
# Handling Server Rendering

👨‍💼 We don't currently do any server rendering, but in the future we may want to
and this requires some special handling with `useSyncExternalStore`.

🧝‍♂️ I've simulated a server rendering environment by adding some code to the
bottom of our file. First, we render the `<App />` to a string, then we set that
to the `innerHTML` of our `rootEl`. Then we call `hydrateRoot` to rehydrate our
application.

👨‍💼 This is a bit of a hack, but it's a good way to simulate server rendering
and ensure that our application works in a server rendering situation.

Because the server won't know whether a media query matches, we can't use the
`getServerSnapshot()` argument of `useSyncExternalStore`. Instead, we'll leave
that argument off, and wrap our `<NarrowScreenNotifier />` in a `<Suspense />`
component with a fallback of `""` (we won't show anything until the client
hydrates).

With this, you'll notice there's an error in the console. Nothing's technically
wrong, but React logs this in this situation (I honestly personally disagree
that they should do this, but 🤷‍♂️). So as extra credit, you can add an
`onRecoverableError` function to the `hydrateRoot` call and if the given error
includes the string `'Missing getServerSnapshot'` then you can return,
otherwise, log the error.

Good luck!
9 changes: 9 additions & 0 deletions exercises/09.sync-external/03.solution.ssr/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
# Handling Server Rendering

👨‍💼 Great work! You now know how to properly handle server rendering of something
we don't know until the client-render when it comes to an external store like
this.

🦉 There are more things you can do for different cases (like the user's
light/dark mode preference) to offer a better user experience. Check out
[`@epic-web/client-hints`](https://www.npmjs.com/package/@epic-web/client-hints)
to see how you can handle this even better if you're interested.
3 changes: 3 additions & 0 deletions exercises/09.sync-external/FINISHED.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Sync External State

👨‍💼 Great work! You now know how to integrate React with external bits of
changing state. Well done!
100 changes: 100 additions & 0 deletions exercises/09.sync-external/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,101 @@
# Sync External State

Not everything we build on the web is built with React. There are libraries and
platform APIs that are external to React. Bringing those things into React's
component model and state management lifecycle is necessary for building
full-featured applications.

The task is to synchronize the external world with the internal state of a
React component. To do this, we use the `useSyncExternalStore` hook.

Let's take the example of a component that displays your current location via
the geolocation API. The geolocation API is not a part of React, so we need to
synchronize the external state of the geolocation API with the internal state of
our component.

```tsx lines=31-35
import { useSyncExternalStore } from 'react'

type LocationData =
| { status: 'unavailable'; geo?: never }
| { status: 'available'; geo: GeolocationPosition }
// this variable is our external store!
let location: LocationData = { status: 'unavailable' }

function subscribeToGeolocation(callback: () => void) {
const watchId = navigator.geolocation.watchPosition(position => {
location = { status: 'available', geo: position }
callback()
})
return () => {
location = { status: 'unavailable' }
return navigator.geolocation.clearWatch(watchId)
}
}

function getGeolocationSnapshot() {
return location
}

function MyLocation() {
const location = useSyncExternalStore(
subscribeToGeolocation,
getGeolocationSnapshot,
)
return (
<div>
{location.status === 'unavailable' ? (
'Your location is unavailable'
) : (
<>
Your location is {location.geo.coords.latitude.toFixed(2)}
{'°, '}
{location.geo.coords.longitude.toFixed(2)}
{'°'}
</>
)}
</div>
)
}

function App() {
return (
<Suspense fallback="loading location...">
<MyLocation />
</Suspense>
)
}
```

Here's the basic API:

```tsx
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot, // optional
)
```

- `subscribe` is a function that takes a callback and returns a cleanup function.
The callback is called whenever the external store changes to let React know
it should call `getSnapshot` to get the new value.
- `getSnapshot` is a function that returns the current value of the external
store.
- `getServerSnapshot` is an optional function that returns the current value of
the external store from the server. This is useful for server-side rendering
and rehydration. If you don't provide this function, then React will render
the nearest `Suspense` boundary `fallback` on the server and then when the
client hydrates, it will call `getSnapshot` to get the current value.

<callout-success class="aside">
To learn more about server rendering React, check [the
docs](https://react.dev/reference/react-dom/server).
</callout-success>

The `Suspense` Component is something we'll get to in a future workshop. For
now, think of it as a way to declaratively handle loading states in React
(because that's what it is and that's all you need to know for now).

Learn more about `useSyncExternalStore` in the
[API documentation](https://react.dev/reference/react/useSyncExternalStore).

0 comments on commit 477449c

Please sign in to comment.