Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce re-renders by auto detecting state dependencies #186

Merged
merged 6 commits into from
Dec 28, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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