Skip to content

Commit

Permalink
Refactor to useSyncExternalStore (#27)
Browse files Browse the repository at this point in the history
* Refactor to useSyncExternalStore

* Avoid creating array in getSnapshot

* Add store to dependency array

* Add tentative changeset

* Update changeset

---------

Co-authored-by: Hendrik Mans <[email protected]>
  • Loading branch information
smartinio and hmans authored Aug 1, 2023
1 parent 8c555e2 commit 8928b16
Show file tree
Hide file tree
Showing 3 changed files with 52 additions and 39 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-poems-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"statery": minor
---

Statery's React hooks now internally use React 18's new `useSyncExternalStore` hook. This simplifies the library implementation and makes sure that store updates don't cause UI drift.
62 changes: 23 additions & 39 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { useCallback, useRef, useSyncExternalStore } from "react"

/*
Expand Down Expand Up @@ -155,61 +155,45 @@ export const makeStore = <T extends IState>(initialState: T): Store<T> => {
*/

/**
* If a component is loaded in a SSR context and imports the useStore hook,
* React will trigger a warning that says: "useLayoutEffect does nothing on
* the server". To surpress this warning, we need to check if window is
* defined.
*/
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect

/**
* Provides reactive read access to a Statery store. Returns a proxy object that
* provides direct access to the store's state and makes sure that the React component
* it was invoked from automaticaly re-renders when any of the data it uses is updated.
* it was invoked from automatically re-renders when any of the data it uses is updated.
*
* @param store The Statery store to access.
*/
export const useStore = <T extends IState>(store: Store<T>): T => {
/* A cheap version state that we will bump in order to re-render the component. */
const [v, setVersion] = useState(0)

/* A set containing all props that we're interested in. */
const subscribedProps = useConst(() => new Set<keyof T>())
const prevSnapshot = useRef(store.state)

/* Grab a copy of the state at the time the component is rendering; then, in an effect,
check if there have already been any updates. This can happen because something that
was rendered alongside this component wrote into the store immediately, possibly
through a function ref. If we detect a change related to the props we're interested in,
force the component to reload. */
const initialState = useConst(() => store.state)
const subscribe = useCallback(
(listener: () => void) => {
store.subscribe(listener)
return () => store.unsubscribe(listener)
},
[store]
)

useIsomorphicLayoutEffect(() => {
if (store.state === initialState) return
const getSnapshot = useCallback(() => {
let hasChanged = false

subscribedProps.forEach((prop) => {
if (initialState[prop] !== store.state[prop]) {
setVersion((v) => v + 1)
return
for (const prop of subscribedProps) {
if (store.state[prop] !== prevSnapshot.current[prop]) {
hasChanged = true
break
}
})
}, [store])
}

/* Subscribe to changes in the store. */
useIsomorphicLayoutEffect(() => {
const listener: Listener<T> = (updates: Partial<T>) => {
/* If there is at least one prop being updated that we're interested in,
bump our local version. */
if (Object.keys(updates).find((prop) => subscribedProps.has(prop))) {
setVersion((v) => v + 1)
}
if (hasChanged) {
prevSnapshot.current = store.state
}

/* Mount & unmount the listener */
store.subscribe(listener)
return () => void store.unsubscribe(listener)
return prevSnapshot.current
}, [store])

const snapshot = useSyncExternalStore(subscribe, getSnapshot)

return new Proxy<Record<any, any>>(
{},
{
Expand All @@ -218,7 +202,7 @@ export const useStore = <T extends IState>(store: Store<T>): T => {
subscribedProps.add(prop)

/* Return the current value of the property. */
return store.state[prop]
return snapshot[prop]
}
}
)
Expand Down
24 changes: 24 additions & 0 deletions test/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,28 @@ describe("useStore", () => {
fireEvent.click(page.getByText("Toggle"))
await page.findByText("Active: No")
})

it("re-renders if a property is changed during the render phase", async () => {
let changedDuringRender = false
let renders = 0

const store = makeStore({ lightning: "Slow" })

const Lightning = () => {
renders++
const { lightning } = useStore(store)

if (!changedDuringRender) {
store.set({ lightning: "Fast" })
changedDuringRender = true
}

return <p>Lightning: {lightning}</p>
}

const page = render(<Lightning />)

await page.findByText("Lightning: Fast")
expect(renders).toBe(2)
})
})

0 comments on commit 8928b16

Please sign in to comment.