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.