diff --git a/src/types.ts b/src/types.ts index 9a1487efa..c4d42adc1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -import { Reducer } from 'react' - export type fetcherFn = (...args: any) => Data | Promise export interface ConfigInterface< Data = any, @@ -110,12 +108,3 @@ export type actionType = { error?: Error isValidating?: boolean } - -export type reducerType = Reducer< - { - data: Data - error: Error - isValidating: boolean - }, - actionType -> diff --git a/src/use-swr-pages.tsx b/src/use-swr-pages.tsx index d2babef3f..2baa17aa0 100644 --- a/src/use-swr-pages.tsx +++ b/src/use-swr-pages.tsx @@ -150,7 +150,12 @@ export function useSWRPages( ) { 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') { diff --git a/src/use-swr.ts b/src/use-swr.ts index 1ab53509f..1c5a0cc78 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -4,9 +4,11 @@ import { useContext, useEffect, useLayoutEffect, - useReducer, - useRef + useState, + useRef, + useMemo } from 'react' + import defaultConfig, { cacheGet, cacheSet, @@ -28,7 +30,6 @@ import { fetcherFn, keyInterface, mutateInterface, - reducerType, responseInterface, RevalidateOptionInterface, triggerInterface, @@ -130,10 +131,6 @@ const mutate: mutateInterface = async (_key, _data, shouldRevalidate) => { } } -function mergeState(state, payload) { - return { ...state, ...payload } -} - function useSWR( key: keyInterface ): responseInterface @@ -189,17 +186,37 @@ function useSWR( const initialData = cacheGet(key) || config.initialData const initialError = cacheGet(keyErr) - let [state, dispatch] = useReducer>(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( @@ -288,18 +305,16 @@ function useSWR( 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 @@ -318,9 +333,7 @@ function useSWR( // 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, @@ -365,7 +378,7 @@ function useSWR( // 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 @@ -374,7 +387,6 @@ function useSWR( !deepEqual(currentHookData, latestKeyedData) ) { dispatch({ data: latestKeyedData }) - dataRef.current = latestKeyedData keyRef.current = key } @@ -418,23 +430,26 @@ function useSWR( ) => { // update hook state const newState: actionType = {} + 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) { @@ -497,7 +512,7 @@ function useSWR( let timer = null const tick = async () => { if ( - !errorRef.current && + !stateRef.current.error && (config.refreshWhenHidden || isDocumentVisible()) && (!config.refreshWhenOffline && isOnline()) ) { @@ -569,19 +584,40 @@ function useSWR( 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 + 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 diff --git a/test/use-swr.test.tsx b/test/use-swr.test.tsx index 383f38441..5f3e9015f 100644 --- a/test/use-swr.test.tsx +++ b/test/use-swr.test.tsx @@ -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 ( +
+ hello, {data}, {isValidating ? 'loading' : 'ready'} +
+ ) + } + + const { container } = render() + 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
hello, {data}
+ } + + const { container } = render() + 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
hello
+ } + + const { container } = render() + 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)