Skip to content

Commit

Permalink
Reduce re-renders by auto detecting state dependencies (#186)
Browse files Browse the repository at this point in the history
* add dependency detection

* remove debugger

* memorize returned state

* memorize dispatch

* fix reference comparison
  • Loading branch information
shuding authored Dec 28, 2019
1 parent 915b008 commit 994806e
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 47 deletions.
11 changes: 0 additions & 11 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Reducer } from 'react'

export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
export interface ConfigInterface<
Data = any,
Expand Down Expand Up @@ -110,12 +108,3 @@ export type actionType<Data, Error> = {
error?: Error
isValidating?: boolean
}

export type reducerType<Data, Error> = Reducer<
{
data: Data
error: Error
isValidating: boolean
},
actionType<Data, Error>
>
7 changes: 6 additions & 1 deletion src/use-swr-pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ export function useSWRPages<OffsetType = any, Data = any, Error = any>(
) {
setPageSWRs(swrs => {
const _swrs = [...swrs]
_swrs[id] = swr
_swrs[id] = {
data: swr.data,
error: swr.error,
revalidate: swr.revalidate,
isValidating: swr.isValidating
}
return _swrs
})
if (typeof swr.data !== 'undefined') {
Expand Down
106 changes: 71 additions & 35 deletions src/use-swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
useContext,
useEffect,
useLayoutEffect,
useReducer,
useRef
useState,
useRef,
useMemo
} from 'react'

import defaultConfig, {
cacheGet,
cacheSet,
Expand All @@ -28,7 +30,6 @@ import {
fetcherFn,
keyInterface,
mutateInterface,
reducerType,
responseInterface,
RevalidateOptionInterface,
triggerInterface,
Expand Down Expand Up @@ -130,10 +131,6 @@ const mutate: mutateInterface = async (_key, _data, shouldRevalidate) => {
}
}

function mergeState(state, payload) {
return { ...state, ...payload }
}

function useSWR<Data = any, Error = any>(
key: keyInterface
): responseInterface<Data, Error>
Expand Down Expand Up @@ -189,17 +186,37 @@ function useSWR<Data = any, Error = any>(
const initialData = cacheGet(key) || config.initialData
const initialError = cacheGet(keyErr)

let [state, dispatch] = useReducer<reducerType<Data, Error>>(mergeState, {
// if a state is accessed (data, error or isValidating),
// we add the state to dependencies so if the state is
// updated in the future, we can trigger a rerender
const stateDependencies = useRef({
data: false,
error: false,
isValidating: false
})
const stateRef = useRef({
data: initialData,
error: initialError,
isValidating: false
})

const rerender = useState(null)[1]
let dispatch = useCallback(payload => {
let shouldUpdateState = false
for (let k in payload) {
stateRef.current[k] = payload[k]
if (stateDependencies.current[k]) {
shouldUpdateState = true
}
}
if (shouldUpdateState || config.suspense) {
rerender({})
}
}, [])

// error ref inside revalidate (is last request errored?)
const unmountedRef = useRef(false)
const keyRef = useRef(key)
const dataRef = useRef(initialData)
const errorRef = useRef(initialError)

// start a revalidation
const revalidate = useCallback(
Expand Down Expand Up @@ -288,18 +305,16 @@ function useSWR<Data = any, Error = any>(
isValidating: false
}

if (typeof errorRef.current !== 'undefined') {
if (typeof stateRef.current.error !== 'undefined') {
// we don't have an error
newState.error = undefined
errorRef.current = undefined
}
if (deepEqual(dataRef.current, newData)) {
if (deepEqual(stateRef.current.data, newData)) {
// deep compare to avoid extra re-render
// do nothing
} else {
// data changed
newState.data = newData
dataRef.current = newData
}

// merge the new state
Expand All @@ -318,9 +333,7 @@ function useSWR<Data = any, Error = any>(

// get a new error
// don't use deep equal for errors
if (errorRef.current !== err) {
errorRef.current = err

if (stateRef.current.error !== err) {
// we keep the stale data
dispatch({
isValidating: false,
Expand Down Expand Up @@ -365,7 +378,7 @@ function useSWR<Data = any, Error = any>(
// we need to update the data from the cache
// and trigger a revalidation

const currentHookData = dataRef.current
const currentHookData = stateRef.current.data
const latestKeyedData = cacheGet(key) || config.initialData

// update the state if the key changed or cache updated
Expand All @@ -374,7 +387,6 @@ function useSWR<Data = any, Error = any>(
!deepEqual(currentHookData, latestKeyedData)
) {
dispatch({ data: latestKeyedData })
dataRef.current = latestKeyedData
keyRef.current = key
}

Expand Down Expand Up @@ -418,23 +430,26 @@ function useSWR<Data = any, Error = any>(
) => {
// update hook state
const newState: actionType<Data, Error> = {}
let needUpdate = false

if (
typeof updatedData !== 'undefined' &&
!deepEqual(dataRef.current, updatedData)
!deepEqual(stateRef.current.data, updatedData)
) {
newState.data = updatedData
dataRef.current = updatedData
needUpdate = true
}

// always update error
// because it can be `undefined`
if (errorRef.current !== updatedError) {
if (stateRef.current.error !== updatedError) {
newState.error = updatedError
errorRef.current = updatedError
needUpdate = true
}

dispatch(newState)
if (needUpdate) {
dispatch(newState)
}

keyRef.current = key
if (shouldRevalidate) {
Expand Down Expand Up @@ -497,7 +512,7 @@ function useSWR<Data = any, Error = any>(
let timer = null
const tick = async () => {
if (
!errorRef.current &&
!stateRef.current.error &&
(config.refreshWhenHidden || isDocumentVisible()) &&
(!config.refreshWhenOffline && isOnline())
) {
Expand Down Expand Up @@ -569,19 +584,40 @@ function useSWR<Data = any, Error = any>(
error: latestError,
data: latestData,
revalidate,
isValidating: state.isValidating
isValidating: stateRef.current.isValidating
}
}

return {
// `key` might be changed in the upcoming hook re-render,
// but the previous state will stay
// so we need to match the latest key and data (fallback to `initialData`)
error: keyRef.current === key ? state.error : initialError,
data: keyRef.current === key ? state.data : initialData,
revalidate, // handler
isValidating: state.isValidating
}
// define returned state
// can be memorized since the state is a ref
return useMemo(() => {
const state = { revalidate } as responseInterface<Data, Error>
Object.defineProperties(state, {
error: {
// `key` might be changed in the upcoming hook re-render,
// but the previous state will stay
// so we need to match the latest key and data (fallback to `initialData`)
get: function() {
stateDependencies.current.error = true
return keyRef.current === key ? stateRef.current.error : initialError
}
},
data: {
get: function() {
stateDependencies.current.data = true
return keyRef.current === key ? stateRef.current.data : initialData
}
},
isValidating: {
get: function() {
stateDependencies.current.isValidating = true
return stateRef.current.isValidating
}
}
})

return state
}, [revalidate])
}

const SWRConfig = SWRConfigContext.Provider
Expand Down
76 changes: 76 additions & 0 deletions test/use-swr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,82 @@ describe('useSWR', () => {
})
})

describe('useSWR - loading', () => {
afterEach(cleanup)

const loadData = () => new Promise(res => setTimeout(() => res('data'), 100))

it('should return loading state', async () => {
let renderCount = 0
function Page() {
const { data, isValidating } = useSWR('is-validating-1', loadData)
renderCount++
return (
<div>
hello, {data}, {isValidating ? 'loading' : 'ready'}
</div>
)
}

const { container } = render(<Page />)
expect(container.textContent).toMatchInlineSnapshot(`"hello, , loading"`)
await waitForDomChange({ container })
expect(container.textContent).toMatchInlineSnapshot(`"hello, data, ready"`)

// data isValidating
// -> undefined, false
// -> undefined, true
// -> data, false
expect(renderCount).toEqual(3)
})

it('should avoid extra rerenders', async () => {
let renderCount = 0
function Page() {
// we never access `isValidating`, so it will not trigger rerendering
const { data } = useSWR('is-validating-2', loadData)
renderCount++
return <div>hello, {data}</div>
}

const { container } = render(<Page />)
await waitForDomChange({ container })
expect(container.textContent).toMatchInlineSnapshot(`"hello, data"`)

// data
// -> undefined
// -> data
expect(renderCount).toEqual(2)
})

it('should avoid extra rerenders while fetching', async () => {
let renderCount = 0,
dataLoaded = false
const loadDataWithLog = () =>
new Promise(res =>
setTimeout(() => {
dataLoaded = true
res('data')
}, 100)
)

function Page() {
// we never access anything
useSWR('is-validating-3', loadDataWithLog)
renderCount++
return <div>hello</div>
}

const { container } = render(<Page />)
expect(container.textContent).toMatchInlineSnapshot(`"hello"`)

await act(() => new Promise(res => setTimeout(res, 110))) // wait
// it doesn't re-render, but fetch was triggered
expect(renderCount).toEqual(1)
expect(dataLoaded).toEqual(true)
})
})

describe('useSWR - refresh', () => {
afterEach(cleanup)

Expand Down

0 comments on commit 994806e

Please sign in to comment.