← All React exercises

React implementation · 04

UseSWR() I

StatusNot taken

Problem

SWR is a popular library of data fetching. Let's try to implement the basic usage by ourselves. import React from 'react' function App() { const { data, error } = useSWR('/api', fetcher) if (error) return <div>failed</div> if (!data) return <div>loading</div> return <div>succeeded</div> } this is not to replicate the true implementation of useSWR() The first argument key is for deduplication, we can safely ignore it for now

Solution

Review after your attempt
solution.tsx
1Let's take a closer look at the code example.
2
3function App() {
4  const { data, error } = useSWR('/api', fetcher)
5  if (error) return <div>failed</div>
6  if (!data) return <div>loading</div>
7  return <div>succeeded</div>
8}
9
10Basically it says to create a wrapper around useState(), in which the state should get updated on the changes of fetch status. We can come up with following solution easily with the help of useEffect().
11
12import { useState, useEffect } from 'react'
13type State<T, E> = {
14  data?: T
15  error?: E
16}
17export function useSWR<T = any, E = any>(
18  _key: string,
19  fetcher: () => T | Promise<T>
20): State<T, E> {
21  const [state, setState] = useState<State<T, E>>({})
22  useEffect(() => {
23    const maybePromise = fetcher()
24    if ('then' in maybePromise) {
25      maybePromise
26        .then((data) => {
27          setState({
28            data
29          })
30        })
31        .catch((error) => {
32          setState({
33            error
34          })
35        })
36    } else {
37      setState({
38        data: maybePromise
39      })
40    }
41  }, [fetcher])
42  return state
43}
44
45There are a few problems in above approach though.
46
47    It fails the last test case, which requires the state to be initialized synchronously if fetcher returns a non-thenable value.
48    Under dev mode, fetcher gets called twice due to double-rendering on useEffect .
49    Fetching is bound to the specific component, making it hard to implement deduplication in the future.
50    It doesn't check the mount/unmount status of the components.
51
52If you wonder what is the use case for a synchronous fetcher, check out this thread from swr repo. But indeed though swr supports synchronous fetcher, it doesn't say the state initialization is synchronous. Let's see if we can achieve it without worrying about its actual use case.
53
54The problem is that once we use useEffect, the state is initialized asynchronously. We have to kick off the fetcher and check the value before useState(). Since we cannot call fetcher on every render, memo() or another useState() could be handy here.
55
56import { useState, useEffect, useMemo } from 'react'
57type State<T, E> = {
58  data?: T
59  error?: E
60}
61export function useSWR<T = any, E = any>(
62  _key: string,
63  fetcher: () => T | Promise<T>
64): State<T, E> {
65  const maybePromise = useMemo(fetcher, [_key])
66  const [state, setState] = useState<State<T, E>>(
67    'then' in maybePromise
68      ? {}
69      : {
70          data: maybePromise
71        }
72  )
73Now we can initialize the state synchronously
74  useEffect(() => {
75    if ('then' in maybePromise) {
76      maybePromise
77        .then((data) => {
78          setState({
79            data
80          })
81        })
82        .catch((error) => {
83          setState({
84            error
85          })
86        })
87    }
88  }, [maybePromise])
89  return state
90}
91
92With this change, the non-thenable value is set to state synchronously. 🎉 But rest of the problems are still there.
93
94    useMemo() is still affected by double-rendering during development but it should not harm if the fetcher is idempotent The approach explained after this doesn't have this concern.
95
96In this question, we ignore _key which is to identify different fetcher and dedupe. This is a great idea since we can assume GET requests return same result in a short period of time.
97
98    Check out this coding question with similar idea: 101. merge identical API calls
99
100But with above solution, the fetch status is inside each component, which makes it super hard to share data in other components.
101
102We can think of the data fetching as an external store, and our hook just subscribes to its change. With this idea in mind we can leverage useSyncExternalStore() to create a more complex yet more powerful solution.
103
104Our store should be a map of data fetching instances based on keys, each entry should have its own state.
105
106type Fetcher<T> = () => T | Promise<T>
107type Callback = () => void
108const store = new Map<string, Entry>()
109class Entry<T = any, E = any> {
110  maybePromise: ReturnType<Fetcher<T>>
111  state: {
112    data?: T
113    error?: E
114  } = {}
115  constructor(fetcher: Fetcher<T>) {
116    this.maybePromise = fetcher()
117kick off the fetcher right away
118    if ('then' in this.maybePromise) {
119      this.maybePromise
120        .then((data) => {
121          this.state = {
122            data: data
123          }
124        })
125        .catch((error) => {
126          this.state = {
127            error: error
128          }
129        })
130    } else {
131      this.state = {
132        data: this.maybePromise
133      }
134    }
135  }
136}
137function getEntry<T>(key: string, fetcher: Fetcher<T>): Entry<T> {
138  const entry = store.get(key)
139  if (entry) {
140    return entry
141  }
142  const newEntry = new Entry(fetcher)
143  store.set(key, newEntry)
144  return newEntry
145}
146Here `getEntry()` lazily returns the entry, which makes it idempotent.
147
148To adapt to useSyncExternalStore(), it also need to support events.
149
150import { useState, useLayoutEffect, useSyncExternalStore } from 'react'
151type Fetcher<T> = () => T | Promise<T>
152type Callback = () => void
153const store = new Map<string, Entry>()
154class Entry<T = any, E = any> {
155  callbacks: Array<Callback> = []
156  maybePromise: ReturnType<Fetcher<T>>
157  state: {
158    data?: T
159    error?: E
160  } = {}
161  constructor(fetcher: Fetcher<T>) {
162    this.maybePromise = fetcher()
163    if ('then' in this.maybePromise) {
164      this.maybePromise
165        .then((data) => {
166          this.state = {
167            data: data
168          }
169        })
170        .catch((error) => {
171          this.state = {
172            error: error
173          }
174        })
175        .finally(this.emitChange)
176    } else {
177      this.state = {
178        data: this.maybePromise
179      }
180    }
181  }
182  emitChange = () => {
183    this.callbacks.forEach((callback) => callback())
184  }
185  subscribe = (callback: Callback) => {
186    this.callbacks.push(callback)
187    return () => (this.callbacks = this.callbacks.filter((_) => _ != callback))
188  }
189  getSnapshot = () => {
190    return this.state
191  }
192}
193
194Or we can use EventTarget directly if it is supported.
195
196class Entry<T = any, E = any> extends EventTarget {
197  maybePromise: ReturnType<Fetcher<T>>
198  state: {
199    data?: T
200    error?: E
201  } = {}
202  constructor(fetcher: Fetcher<T>) {
203    super()
204    this.maybePromise = fetcher()
205    if ('then' in this.maybePromise) {
206      this.maybePromise
207        .then((data) => {
208          this.state = {
209            data: data
210          }
211        })
212        .catch((error) => {
213          this.state = {
214            error: error
215          }
216        })
217        .finally(this.emitChange)
218    } else {
219      this.state = {
220        data: this.maybePromise
221      }
222    }
223  }
224  emitChange = () => {
225    this.dispatchEvent(new CustomEvent('update'))
226  }
227  subscribe = (callback: Callback) => {
228    this.addEventListener('update', () => callback())
229  }
230  getSnapshot = () => {
231    return this.state
232  }
233}
234
235Now we can easily integrate useSyncExternalStore() in our implementation.
236
237import { useState, useLayoutEffect, useSyncExternalStore } from 'react'
238{...}
239export function useSWR<T = any, E = any>(
240  _key: string,
241  fetcher: Fetcher<T>
242): {
243  data?: T
244  error?: E
245} {
246  const entry = getEntry(_key, fetcher)
247  const data = useSyncExternalStore(entry.subscribe, entry.getSnapshot)
248  return data
249}
250
251This is a very primitive solution, you can come up with better code on the store, but it is a base for follow-ups like actual deduplication, cache evicting .etc.