在本教程中,我想通過state和effect hook來像你展示如何用React Hooks來獲取資料。我將會使用Hacker News的API來獲取熱門的技術文章。你將會實現一個屬於你自己的自定義hook來在你程式的任何地方複用,或者是作為一個npm包釋出出來。
如果你還不知道這個React的新特性,那麼點選React Hooks介紹,如果你想直接檢視最後的實現效果,請點選這個github倉庫。
注意:在未來,React Hooks將不會用於React的資料獲取,一個叫做Suspense的特性將會去負責它。但下面的教程仍會讓你去更多的瞭解關於React中的state和effect hook。
用React Hooks去獲取資料
如果你對在React中獲取資料還不熟悉,可以檢視我其他的React獲取資料的文章。它將會引導你通過使用React的class元件來獲取資料,並且還可以和render props或者高階元件一起使用,以及結合錯誤處理和載入狀態。在這篇文章中,我將會在function元件中使用React Hooks來展示這些功能。
import React, { useState } from 'react';
function App() {
const [data, setData] = useState({ hits: [] });
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
複製程式碼
這個App元件展示了一個包含很多項的list(hits = Hacker News 文章)。state和state的更新函式來自於state hook中useState的呼叫,它負責管理我們用來渲染list資料的本地狀態,初始狀態是一個空陣列,此時還沒有為其設定任何的狀態。
我們將使用axios來獲取資料,當然你也可以使用其他的庫或者fetch API,如果你還沒安裝axios,你可以在命令列使用npm install axios
來安裝它。然後來實現用於資料獲取的effect hook:
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
useEffect(async () => {
const result = await axios(
'http://hn.algolia.com/api/v1/search?query=redux',
);
setData(result.data);
});
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
複製程式碼
通過axios在useEffect中獲取資料,然後通過setData將資料放到元件本地的state中,並通過async/await來處理Promise。
然而當你執行程式的時候,你應該會遇到一個討厭的迴圈。effect hook不僅在元件mount的時候也會在update的時候執行。因為我們在每一次的資料獲取之後,會去通過setState設定狀態,這時候元件update然後effect就會執行一遍,這就造成了資料一次又一次的獲取。我們僅僅是想要在元件mount的時候來獲取一次資料,這就是為什麼我們需要在useEffect的第二個引數提供一個空陣列,從而實現只在mount的時候觸發資料獲取而不是每一次update。
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
useEffect(async () => {
const result = await axios(
'http://hn.algolia.com/api/v1/search?query=redux',
);
setData(result.data);
}, []);
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
複製程式碼
第二個引數可以定義hooks所依賴的變數(在一個陣列中去分配),如果一個變數改變了,hooks將會執行一次,如果是一個空陣列的話,hooks將不會在元件更新的時候執行,因為它沒有監聽到任何的變數。
這裡還有一個陷阱,在程式碼中,我們使用async/await從第三方的API中獲取資料,根據文件,每一個async函式都將返回一個promise,async函式宣告定義了一個非同步函式,它返回一個asyncFunction物件,非同步函式是通過事件迴圈非同步操作的函式,使用隱式Promise返回其結果。但是,effect hook應該不返回任何內容或清除功能,這就是為什麼你會在控制檯看到以下警告:07:41:22.910 index.js:1452 Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async () => …) are not supported, but you can call an async function inside an effect..
這就是為什麼不允許在useEffect函式中直接使用async的原因。讓我們通過在effect內部使用非同步函式來實現它的解決方案。
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
useEffect(() => {
const fetchData = async () => {
const result = await axios(
'http://hn.algolia.com/api/v1/search?query=redux',
);
setData(result.data);
};
fetchData();
}, []);
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
複製程式碼
簡而言之,這就是用React Hooks獲取資料。但是,如果你對錯誤處理、載入提示、如何從表單中觸發資料獲取以及如何實現可重用的資料獲取hook感興趣,請繼續閱讀。
如何通過程式設計方式/手動方式觸發hook?
好的,我們在mount後獲取了一次資料,但是,如果使用input的欄位來告訴API哪一個話題是我們感興趣的呢?“Redux”可以作為我們的預設查詢,如果是關於“React”的呢?讓我們實現一個input元素,使某人能夠獲取“Redux”以外的話題。因此,為input元素引入一個新的狀態。
import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
useEffect(() => {
const fetchData = async () => {
const result = await axios(
'http://hn.algolia.com/api/v1/search?query=redux',
);
setData(result.data);
};
fetchData();
}, []);
return (
<Fragment>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</Fragment>
);
}
export default App;
複製程式碼
目前,這兩個狀態彼此獨立,但現在希望將它們耦合起來,以獲取由input中的輸入來查詢指定的專案。通過下面的更改,元件應該在掛載之後通過查詢詞獲取所有資料。
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
setData(result.data);
};
fetchData();
}, []);
return (
...
);
}
export default App;
複製程式碼
還差一部分:當你嘗試在input中輸入一些內容時,在mount之後就不會再獲取任何資料了,這是因為我們提供了空陣列作為第二個引數,effect沒有依賴任何變數,因此只會在mount的時候觸發,但是現在的effect應該依賴query,每當query改變的時候,就應該觸發資料的獲取。
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
setData(result.data);
};
fetchData();
}, [query]);
return (
...
);
}
export default App;
複製程式碼
現在每當input的值更新的時候就可以重新獲取資料了。但這又導致了另一個問題:對於input中鍵入的每個字元,都會觸發該效果,並執行一個資料提取請求。如何提供一個按鈕來觸發請求,從而手動hook呢?
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
const [search, setSearch] = useState('');
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
setData(result.data);
};
fetchData();
}, [query]);
return (
<Fragment>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type="button" onClick={() => setSearch(query)}>
Search
</button>
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</Fragment>
);
}
複製程式碼
現在,effect依賴於於search,而不是隨輸入欄位中變化的query。一旦使用者點選按鈕,新的search就會被設定,並且應該手動觸發effect hook。
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
const [search, setSearch] = useState('redux');
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${search}`,
);
setData(result.data);
};
fetchData();
}, [search]);
return (
...
);
}
export default App;
複製程式碼
此外,search的初始值也設定為與query相同,因為元件也在mount時獲取資料,因此結果應反映輸入欄位中的值。但是,具有類似的query和search狀態有點令人困惑。為什麼不將實際的URL設定為狀態而來代替search?
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
const [url, setUrl] = useState(
'http://hn.algolia.com/api/v1/search?query=redux',
);
useEffect(() => {
const fetchData = async () => {
const result = await axios(url);
setData(result.data);
};
fetchData();
}, [url]);
return (
<Fragment>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type="button"
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</Fragment>
);
}
複製程式碼
這就是使用effect hook獲取隱式程式設計資料的情況。你可以決定effect依賴於哪個狀態。一旦在點選或其他effect中設定此狀態,此effect將再次執行。在這種情況下,如果URL狀態發生變化,effect將再次執行以從API獲取資料。
React Hooks和loading
讓我們為資料獲取引入一個載入提示。它只是另一個由state hook管理的狀態。loading被用於在元件中渲染一個loading提示。
import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
const [url, setUrl] = useState(
'http://hn.algolia.com/api/v1/search?query=redux',
);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
const result = await axios(url);
setData(result.data);
setIsLoading(false);
};
fetchData();
}, [url]);
return (
<Fragment>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type="button"
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</Fragment>
);
}
export default App;
複製程式碼
一旦呼叫該effect進行資料獲取(當元件mount或URL狀態更改時發生),載入狀態將設定為true。一旦請求完成,載入狀態將再次設定為false。
React Hooks和錯誤處理
如果在React Hooks中加上錯誤處理呢,錯誤只是用state hook初始化的另一個狀態。一旦出現錯誤狀態,應用程式元件就可以為使用者提供反饋。使用async/await時,通常使用try/catch塊進行錯誤處理。你可以在effect內做到:
import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
const [url, setUrl] = useState(
'http://hn.algolia.com/api/v1/search?query=redux',
);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const result = await axios(url);
setData(result.data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
return (
<Fragment>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type="button"
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
{isError && <div>Something went wrong ...</div>}
{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</Fragment>
);
}
export default App;
複製程式碼
React在表單中獲取資料
到目前為止,我們只有input和按鈕的組合。一旦引入更多的輸入元素,您可能需要用一個表單元素包裝它們。此外,表單還可以通過鍵盤上的“enter”來觸發。
function App() {
...
return (
<Fragment>
<form
onSubmit={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type="submit">Search</button>
</form>
{isError && <div>Something went wrong ...</div>}
...
</Fragment>
);
}
複製程式碼
但是現在瀏覽器在單擊提交按鈕時頁面會重新載入,因為這是瀏覽器在提交表單時的固有行為。為了防止預設行為,我們可以通過event.preventDefault()取消預設行為。這也是在React類元件中實現的方法。
function App() {
...
const doFetch = () => {
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
};
return (
<Fragment>
<form onSubmit={event => {
doFetch();
event.preventDefault();
}}>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type="submit">Search</button>
</form>
{isError && <div>Something went wrong ...</div>}
...
</Fragment>
);
}
複製程式碼
現在,當你單擊提交按鈕時,瀏覽器不會再重新載入。它和以前一樣工作,但這次使用的是表單,而不是簡單的input和按鈕組合。你也可以按鍵盤上的“回車”鍵。
自定義資料獲取hook
為了提取用於資料獲取的自定義hook,請將屬於資料獲取的所有內容,移動到一個自己的函式中。還要確保能夠返回App元件所需要的全部變數。
const useHackerNewsApi = () => {
const [data, setData] = useState({ hits: [] });
const [url, setUrl] = useState(
'http://hn.algolia.com/api/v1/search?query=redux',
);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const result = await axios(url);
setData(result.data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
const doFetch = () => {
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
};
return { data, isLoading, isError, doFetch };
}
複製程式碼
現在,你可以在App元件中使用新的hook了。
function App() {
const [query, setQuery] = useState('redux');
const { data, isLoading, isError, doFetch } = useHackerNewsApi();
return (
<Fragment>
...
</Fragment>
);
}
複製程式碼
接下來,從dofetch函式外部傳遞URL狀態:
const useHackerNewsApi = () => {
...
useEffect(
...
);
const doFetch = url => {
setUrl(url);
};
return { data, isLoading, isError, doFetch };
};
function App() {
const [query, setQuery] = useState('redux');
const { data, isLoading, isError, doFetch } = useHackerNewsApi();
return (
<Fragment>
<form
onSubmit={event => {
doFetch(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
event.preventDefault();
}}
>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type="submit">Search</button>
</form>
...
</Fragment>
);
}
複製程式碼
初始狀態也可以變為通用狀態。把它簡單地傳遞給新的自定義hook:
import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
const useDataApi = (initialUrl, initialData) => {
const [data, setData] = useState(initialData);
const [url, setUrl] = useState(initialUrl);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const result = await axios(url);
setData(result.data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
const doFetch = url => {
setUrl(url);
};
return { data, isLoading, isError, doFetch };
};
function App() {
const [query, setQuery] = useState('redux');
const { data, isLoading, isError, doFetch } = useDataApi(
'http://hn.algolia.com/api/v1/search?query=redux',
{ hits: [] },
);
return (
<Fragment>
<form
onSubmit={event => {
doFetch(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
event.preventDefault();
}}
>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type="submit">Search</button>
</form>
{isError && <div>Something went wrong ...</div>}
{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</Fragment>
);
}
export default App;
複製程式碼
這就是使用自定義hook獲取資料的方法。hook本身對API一無所知。它從外部接收所有引數,只管理必要的狀態,如資料、載入和錯誤狀態。它執行請求並將資料作為自定義資料獲取hook返回給元件。
Reducer的資料獲取hook
reducer hook返回一個狀態物件和一個改變狀態物件的函式。dispatch函式接收type和可選的payload。所有這些資訊都在實際的reducer函式中使用,從以前的狀態、包含可選payload和type的action中提取新的狀態。讓我們看看這在程式碼中是如何工作的:
import React, {
Fragment,
useState,
useEffect,
useReducer,
} from 'react';
import axios from 'axios';
const dataFetchReducer = (state, action) => {
...
};
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
...
};
複製程式碼
Reducer Hook接受reducer函式和一個初始化的狀態物件作為引數,在我們的例子中,資料、載入和錯誤狀態的初始狀態的引數沒有改變,但是它們被聚合到由一個reducer hook管理的一個狀態物件,而不是單個state hook。
const dataFetchReducer = (state, action) => {
...
};
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
useEffect(() => {
const fetchData = async () => {
dispatch({ type: 'FETCH_INIT' });
try {
const result = await axios(url);
dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
} catch (error) {
dispatch({ type: 'FETCH_FAILURE' });
}
};
fetchData();
}, [url]);
...
};
複製程式碼
現在,在獲取資料時,可以使用dispatch向reducer函式傳送資訊。dispatch函式傳送的物件包括一個必填的type屬性和可選的payload。type告訴Reducer函式需要應用哪個狀態轉換,並且Reducer還可以使用payload來提取新狀態。畢竟,我們只有三種狀態轉換:初始化獲取過程,通知成功的資料獲取結果,以及通知錯誤的資料獲取結果。
在自定義hook的最後,狀態像以前一樣返回,但是因為我們有一個狀態物件,而不再是獨立狀態,所以需要用擴充套件運算子返回state。這樣,呼叫useDataApi自定義hook的使用者仍然可以訪問data、isloading和isError:
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
...
const doFetch = url => {
setUrl(url);
};
return { ...state, doFetch };
};
複製程式碼
最後,還缺少了reducer函式的實現。它需要處理三種不同的狀態轉換,即FETCH_INIT、FETCH_SUCCESS和FETCH_FAILURE。每個狀態轉換都需要返回一個新的狀態物件。讓我們看看如何用switch case語句實現這一點:
const dataFetchReducer = (state, action) => {
switch (action.type) {
case 'FETCH_INIT':
return { ...state };
case 'FETCH_SUCCESS':
return { ...state };
case 'FETCH_FAILURE':
return { ...state };
default:
throw new Error();
}
};
複製程式碼
reducer函式可以通過其引數訪問當前狀態和action。到目前為止,switch case語句中的每個狀態轉換隻會返回原來的狀態。...
語句用於保持狀態物件不變(意味著狀態永遠不會直接改變),現在,讓我們重寫一些當前狀態返回的屬性,以便在每次狀態轉換時更改狀態:
const dataFetchReducer = (state, action) => {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
isError: false
};
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
data: action.payload,
};
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
};
default:
throw new Error();
}
};
複製程式碼
現在,每個狀態轉換(由操作的type決定)都將基於先前的狀態和可選的payload返回一個新的狀態。例如,在成功請求的情況下,payload用於設定新狀態物件的資料。
總之,reducer hook確保狀態管理的這一部分是用自己的邏輯封裝的。通過提供type和可選payload,你將始終已一個可預測的狀態結束。此外,你將永遠不會進入無效狀態。例如,以前可能會意外地將isloading和isError狀態設定為true。在這個案例的使用者介面中應該顯示什麼?現在,reducer函式定義的每個狀態轉換都會導致一個有效的狀態物件。
在effect hook中禁止資料獲取
即使元件已經解除安裝(例如,由於使用react路由器導航而離開),設定元件狀態也是react中的一個常見問題。我以前在這裡寫過這個問題,它描述瞭如何防止在各種場景中為unmount的元件設定狀態。讓我們看看如何防止在自定義hook中為資料獲取設定狀態:
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
dispatch({ type: 'FETCH_INIT' });
try {
const result = await axios(url);
if (!didCancel) {
dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
}
} catch (error) {
if (!didCancel) {
dispatch({ type: 'FETCH_FAILURE' });
}
}
};
fetchData();
return () => {
didCancel = true;
};
}, [url]);
const doFetch = url => {
setUrl(url);
};
return { ...state, doFetch };
};
複製程式碼
每個effect hook都有一個clean功能,在元件解除安裝時執行。clean函式是從hook返回的一個函式。在我們的例子中,我們使用一個名為didCancel的布林標誌,讓我們的資料獲取邏輯知道元件的狀態(已裝載/未裝載)。如果元件已解除安裝,則標誌應設定為“tree”,這將導致在最終非同步解決資料提取後無法設定元件狀態。
注意:事實上,資料獲取不會中止——這可以通過axios的Cancellation實現——但是對於未安裝的元件,狀態轉換會不再執行。因為在我看來,axios的Cancellation並不是最好的API,所以這個防止設定狀態的布林標誌也能起到作用。
你已經瞭解了在React中state和effect hook如何用於獲取資料。如果您對使用render props和高階元件在類元件(和函式元件)中獲取資料很感興趣,請從一開始就去我的另一篇文章。否則,我希望本文對您瞭解react hook以及如何在現實場景中使用它們非常有用。