React SWR library is an open source tool from Vercel, the same team that developed Nex.js. Its function is mainly used to implement the stale-while-revalidate cache invalidation policy named in THE HTTP RFC 5861 specification. In simple terms, the ability to retrieve data from the cache first, then send a request for validation, and finally update the effect of the data. So that you can update the UI ahead of time. This improves the user experience when the network speed is low and the cache is available. The following article is mainly about the source code for some analysis and learning.

Learn about interfaces

In the process of using the SWR library, we mainly use the useSWR interface.

The input

The input of the useSWR interface consists of the following parameters:

  • Key: Method used to identify the cached key value, string, or return string

  • Fetcher: Request data interface

  • Options: Configures parameters, such as the following

suspense = false : enable React Suspense mode (details) fetcher = window.fetch : the default fetcher function initialData : initial data to be returned (note: This is per-hook) revalidateOnMount : enable or disable automatic revalidation when component is mounted (by default revalidation occurs on mount when initialData is not set, use this flag to force behavior) revalidateOnFocus = true : auto revalidate when window gets focused revalidateOnReconnect = true : automatically revalidate when the browser regains a network connection (via navigator.onLine ) refreshInterval = 0 : polling interval (disabled by default) refreshWhenHidden = false : polling when the window is invisible (if refreshInterval is enabled) refreshWhenOffline = false : polling when the browser is offline (determined by navigator.onLine ) shouldRetryOnError = true : retry when fetcher has an error (details) dedupingInterval = 2000 : dedupe requests with the same key in this time span focusThrottleInterval = 5000 : only revalidate once during a time span loadingTimeout = 3000 : timeout to trigger the onLoadingSlow event errorRetryInterval = 5000 : error retry interval (details) errorRetryCount : max error retry count (details) onLoadingSlow(key, config) : callback function when a request takes too long to load (see loadingTimeout ) onSuccess(data, key, config) : callback function when a request finishes successfully onError(err, key, config) : callback function when a request returns an error onErrorRetry(err, key, config, revalidate, revalidateOps) : handler for error retry compare(a, b) : comparison function used to detect when returned data has changed, to avoid spurious rerenders. By default, dequal/lite is used. isPaused() : function to detect whether pause revalidations, will ignore fetched data and errors when it returns true . Returns false by default.

The output

The output mainly includes the following data:

  • Data: data

  • Error: Indicates error information

  • IsValidating: Whether the request is in progress

  • Mutate (data, shouldRevalidate): Interface for changing cached data

use

Let’s take a look at the specific way to use:

import useSWR from 'swr'
function Profile() {
  const { data, error } = useSWR('/api/user', fetcher)

  if (error) return <div>failed to load</div>
  if(! data)return <div>loading...</div>
  return <div>hello {data.name}!</div>
}
Copy the code

The basic way to use a react hook is the same as a normal react hook, by passing a string as the key and the corresponding fetcher interface to fetch the corresponding data.

process

Now that you know how to use it, let’s look at the actual code implementation. By looking at the source code, the overall implementation process can be divided into the following steps:

  1. Config: This step is used to process user input and convert it into internal processing parameters.

  2. The data is fetched from cache, and memory holds a ref reference object that points to the last request interface (the key in the input is bound to the request reference). If the cache or key is updated, the data needs to be retrieved.

  3. Handle the request operation and expose the external interface.

function useSWR<Data = any.Error = any> (. args: |readonly [Key]
    | readonly [Key, Fetcher<Data> | null]
    | readonly [Key, SWRConfiguration<Data, Error> | undefined]
    | readonly [
        Key,
        Fetcher<Data> | null,
        SWRConfiguration<Data, Error> | undefined
      ]
) :SWRResponse<Data.Error> {

// Process the parameters and serialize the corresponding key information
const [_key, config, fn] = useArgs<Key, SWRConfiguration<Data, Error>, Data>(
  args
)
const [key, fnArgs, keyErr, keyValidating] = cache.serializeKey(_key)


// Save the reference
const initialMountedRef = useRef(false)
const unmountedRef = useRef(false)
const keyRef = useRef(key)


// The data is obtained from the cache. If there is no corresponding data in the cache, then the data is obtained from the configured initialData
const resolveData = () = > {
  const cachedData = cache.get(key)
  return cachedData === undefined ? config.initialData : cachedData
}
const data = resolveData()
const error = cache.get(keyErr)
const isValidating = resolveValidating()

// The main reason for the omission is to define logic for the method

// Trigger the update logic when the component loads or the key changes, and add some event listening functions
useIsomorphicLayoutEffect(() = > {
    / /... omit
}, [key, revalidate])

// Polling processing, which is mainly used to process some polling configuration parameters
useIsomorphicLayoutEffect(() = > {}, [
config.refreshInterval,
config.refreshWhenHidden,
config.refreshWhenOffline,
revalidate
])


// Error handling
if (config.suspense && data === undefined) {
  if (error === undefined) {
    throw revalidate({ dedupe: true})}throw error
}

// Finally return the status information. See the state management section for the logic here
}
Copy the code

The logic of the config

For user input, the priority of defaultConfig + useContext + user-defined config is defaultConfig < useContext < user-defined Config

export default function useArgs<KeyType.ConfigType.Data> (
  args:
    | readonly [KeyType]
    | readonly [KeyType, Fetcher<Data> | null]
    | readonly [KeyType, ConfigType | undefined]
    | readonly [KeyType, Fetcher<Data> | null, ConfigType | undefined]
) :KeyType, (typeof defaultConfig) & ConfigType.Fetcher<Data> | null] {

// This is used to handle parameters such as config
  const config = Object.assign(
    {},
    defaultConfig,
    useContext(SWRConfigContext),
    args.length > 2
      ? args[2]
      : args.length === 2 && typeof args[1= = ='object'
      ? args[1] : {})as (typeof defaultConfig) & ConfigType
Copy the code

Re-update the logic of the data

Revalidate updates data after the component is loaded or when the current state is idle. Need to handle depupe: De-weight logic where the same requests need to be de-weight in a short period of time. Specifies a CONCURRENT_PROMISES variable to hold all requested operations that require parallel.

const revalidate = useCallback(
  async (revalidateOpts: RevalidatorOptions = {}): Promise<boolean> = > {if(! key || ! fn)return false
    if (unmountedRef.current) return false
    if (configRef.current.isPaused()) return false
    const { retryCount = 0, dedupe = false } = revalidateOpts

    let loading = true
    let shouldDeduping =
      typeofCONCURRENT_PROMISES[key] ! = ='undefined' && dedupe


    try {
      cache.set(keyValidating, true)
      setState({
        isValidating: true
      })
      if(! shouldDeduping) { broadcastState( key, stateRef.current.data, stateRef.current.error,true)}let newData: Data
      let startAt: number

      if (shouldDeduping) {
        startAt = CONCURRENT_PROMISES_TS[key]
        newData = await CONCURRENT_PROMISES[key]
      } else {

        if(config.loadingTimeout && ! cache.get(key)) {setTimeout(() = > {
            if (loading)
              safeCallback(() = > configRef.current.onLoadingSlow(key, config))
          }, config.loadingTimeout)
        }

        if(fnArgs ! = =null) { CONCURRENT_PROMISES[key] = fn(... fnArgs) }else {
          CONCURRENT_PROMISES[key] = fn(key)
        }

        CONCURRENT_PROMISES_TS[key] = startAt = now()

        newData = await CONCURRENT_PROMISES[key]

        setTimeout(() = > {
          if (CONCURRENT_PROMISES_TS[key] === startAt) {
            delete CONCURRENT_PROMISES[key]
            delete CONCURRENT_PROMISES_TS[key]
          }
        }, config.dedupingInterval)

        safeCallback(() = > configRef.current.onSuccess(newData, key, config))
      }

      if(CONCURRENT_PROMISES_TS[key] ! == startAt) {return false
      }

      if( MUTATION_TS[key] ! = =undefined &&
        (startAt <= MUTATION_TS[key] ||
          startAt <= MUTATION_END_TS[key] ||
          MUTATION_END_TS[key] === 0)
      ) {
        setState({ isValidating: false })
        return false
      }

      // Set cache
      cache.set(keyErr, undefined)
      cache.set(keyValidating, false)

      const newState: State<Data, Error> = {
        isValidating: false
      }

      if(stateRef.current.error ! = =undefined) {
        newState.error = undefined
      }

      if(! config.compare(stateRef.current.data, newData)) { newState.data = newData }if(! config.compare(cache.get(key), newData)) { cache.set(key, newData) }// merge the new state
      setState(newState)

      if(! shouldDeduping) {// also update other hooks
        broadcastState(key, newData, newState.error, false)}}catch (err) {
      delete CONCURRENT_PROMISES[key]
      delete CONCURRENT_PROMISES_TS[key]
      if (configRef.current.isPaused()) {
        setState({
          isValidating: false
        })
        return false
      }
      // Retrieve the error information from the cache
      cache.set(keyErr, err)

      if(stateRef.current.error ! == err) { setState({isValidating: false.error: err
        })
        if(! shouldDeduping) {// Broadcast status
          broadcastState(key, undefined, err, false)}}// events and retry
      safeCallback(() = > configRef.current.onError(err, key, config))
      if (config.shouldRetryOnError) {
       // Retry mechanism is required to allow deweighting
        safeCallback(() = >
          configRef.current.onErrorRetry(err, key, config, revalidate, {
            retryCount: retryCount + 1.dedupe: true
          })
        )
      }
    }

    loading = false
    return true
  },
  [key]
)
Copy the code

In addition, the Mutate interface is an outgoing interface that the user calls explicitly to trigger a re-update of the data. For example, when a user logs in again and needs to explicitly re-update all data, the mutate interface can be used. Its implementation logic is as follows:

async function mutate<Data = any> (_key: Key, _data? : Data |Promise<Data | undefined> | MutatorCallback<Data>,
  shouldRevalidate = true
) :Promise<Data | undefined> {
  const [key, , keyErr] = cache.serializeKey(_key)
  if(! key)return undefined

  // if there is no new data to update, let's just revalidate the key
  if (typeof _data === 'undefined') return trigger(_key, shouldRevalidate)

  // update global timestamps
  MUTATION_TS[key] = now() - 1
  MUTATION_END_TS[key] = 0

  // Trace the timestamp
  const beforeMutationTs = MUTATION_TS[key]

  let data: any.error: unknown
  let isAsyncMutation = false

  if (typeof _data === 'function') {
    // `_data` is a function, call it passing current cache value
    try {
      _data = (_data as MutatorCallback<Data>)(cache.get(key))
    } catch (err) {
      // if `_data` function throws an error synchronously, it shouldn't be cached
      _data = undefined
      error = err
    }
  }

  if (_data && typeof (_data as Promise<Data>).then === 'function') {
    // `_data` is a promise
    isAsyncMutation = true
    try {
      data = await _data
    } catch (err) {
      error = err
    }
  } else {
    data = _data
  }

  const shouldAbort = (): boolean | void= > {
    // check if other mutations have occurred since we've started this mutation
    if(beforeMutationTs ! == MUTATION_TS[key]) {if (error) throw error
      return true}}// if there's a race we don't update cache or broadcast change, just return the data
  if (shouldAbort()) return data

  if(data ! = =undefined) {
    // update cached data
    cache.set(key, data)
  }
  // always update or reset the error
  cache.set(keyErr, error)

  // Reset the timestamp to indicate that the update is complete
  MUTATION_END_TS[key] = now() - 1

  if(! isAsyncMutation) {// we skip broadcasting if there's another mutation happened synchronously
    if (shouldAbort()) return data
  }

  // Update phase
  const updaters = CACHE_REVALIDATORS[key]
  if (updaters) {
    const promises = []
    for (let i = 0; i < updaters.length; ++i) { promises.push( updaters[i](!! shouldRevalidate, data, error,undefined, i > 0))}// Return the updated data
    return Promise.all(promises).then(() = > {
      if (error) throw error
      return cache.get(key)
    })
  }
  // Error handling
  if (error) throw error
  return data
}
Copy the code

Caching logic

For the cache of the update, SWR source code specialized to do a package, and the use of subscription – publish mode to listen to the operation of the cache. Here is the cache file: //cache.ts

import { Cache as CacheType, Key, CacheListener } from './types'
import hash from './libs/hash'

export default class Cache implements CacheType {
  private cache: Map<string.any>
  private subs: CacheListener[]

  constructor(initialData: any = {}) {
    this.cache = new Map(Object.entries(initialData))
    this.subs = []
  }

  get(key: Key): any {
    const [_key] = this.serializeKey(key)
    return this.cache.get(_key)
  }

  set(key: Key, value: any) :any {
    const [_key] = this.serializeKey(key)
    this.cache.set(_key, value)
    this.notify()
  }

  keys() {
    return Array.from(this.cache.keys())
  }

  has(key: Key) {
    const [_key] = this.serializeKey(key)
    return this.cache.has(_key)
  }

  clear() {
    this.cache.clear()
    this.notify()
  }

  delete(key: Key) {
    const [_key] = this.serializeKey(key)
    this.cache.delete(_key)
    this.notify()
  }

  // TODO: introduce namespace for the cache
  serializeKey(key: Key): [string.any.string.string] {
    let args = null
    if (typeof key === 'function') {
      try {
        key = key()
      } catch (err) {
        // dependencies not ready
        key = ' '}}if (Array.isArray(key)) {
      // args array
      args = key
      key = hash(key)
    } else {
      key = String(key || ' ')}const errorKey = key ? 'err@' + key : ' '
    const isValidatingKey = key ? 'validating@' + key : ' '

    return [key, args, errorKey, isValidatingKey]
  }

  subscribe(listener: CacheListener) {
    if (typeoflistener ! = ='function') {
      throw new Error('Expected the listener to be a function.')}let isSubscribed = true
    this.subs.push(listener)

    return () = > {
      if(! isSubscribed)return
      isSubscribed = false
      const index = this.subs.indexOf(listener)
      if (index > -1) {
        this.subs[index] = this.subs[this.subs.length - 1]
        this.subs.length--
      }
    }
  }

  private notify() {
    for (let listener of this.subs) {
      listener()
    }
  }
}
Copy the code

As you can see from the source code, when the cache is updated, the internal notify interface is triggered to notify all the handlers subscribed to the update, so that the data can be better monitored.

State management

The exposed state of the SWR is handled in a responsive manner to trigger automatic updates of the component when subsequent data updates occur. The specific code is as follows:


// The status data with references will automatically trigger Render on subsequent dependency updates
export default function useStateWithDeps<Data.Error.S = State<Data.Error> > (
  state: S,
  unmountedRef: MutableRefObject<boolean>
) :MutableRefObject<S>,
  MutableRefObject<Record<StateKeys.boolean> >,payload: S) = >void
] {

  // Declare the state of an empty object, get its setState, and then call this method if it needs to be rerendered.
  const rerender = useState<object> ({}) [1]

  const stateRef = useRef(state)
  useIsomorphicLayoutEffect(() = > {
    stateRef.current = state
  })
  
  // If a state property is accessed in a component's render function, it needs to be internally marked as a dependency so that subsequent updates to the state data can trigger a rerender.
  const stateDependenciesRef = useRef<StateDeps>({
    data: false.error: false.isValidating: false
  })

  /* Use setState explicitly to trigger state updates */
  const setState = useCallback(
    (payload: S) = > {
      let shouldRerender = false

      for (const _ of Object.keys(payload)) {
        // Type casting to work around the `for... in` loop
        // [https://github.com/Microsoft/TypeScript/issues/3500](https://github.com/Microsoft/TypeScript/issues/3500)
        const k = _ as keyof S & StateKeys

        // If the property hasn't changed, skip
        if (stateRef.current[k] === payload[k]) {
          continue
        }

        stateRef.current[k] = payload[k]

        // If the property has been accessed by an external component, a rerendering is triggered
        if (stateDependenciesRef.current[k]) {
          shouldRerender = true}}if(shouldRerender && ! unmountedRef.current) { rerender({}) } },// config.suspense isn't allowed to change during the lifecycle
    // eslint-disable-next-line react-hooks/exhaustive-deps[])return [stateRef, stateDependenciesRef, setState]
}



function useSWR<Data = any.Error = any> (. args: |readonly [Key]
    | readonly [Key, Fetcher<Data> | null]
    | readonly [Key, SWRConfiguration<Data, Error> | undefined]
    | readonly [
        Key,
        Fetcher<Data> | null,
        SWRConfiguration<Data, Error> | undefined
      ]
) :SWRResponse<Data.Error> {
//...


const [stateRef, stateDependenciesRef, setState] = useStateWithDeps<
    Data,
    Error
  >(
    {
      data,
      error,
      isValidating
    },
    unmountedRef
  )

/ /...


// The final returned state is the data that has been responsive wrapped, and the dependencies are updated when the state data is accessed
const state = {
    revalidate,
    mutate: boundMutate
  } as SWRResponse<Data, Error>
  
  Object.defineProperties(state, {
    data: {
      get: function() {
        stateDependenciesRef.current.data = true
        return data
      },
      enumerable: true
    },
    error: {
      get: function() {
        stateDependenciesRef.current.error = true
        return error
      },
      enumerable: true
    },
    isValidating: {
      get: function() {
        stateDependenciesRef.current.isValidating = true
        return isValidating
      },
      enumerable: true}})return state

}
Copy the code