How to fetch data with React Hooks?

2,701 阅读6分钟

How to fetch data with React Hooks?

在这篇教程中,我将通过React Hooks中的useState、useEffect来展示如何获取后端数据。您也可以实现一个自定义的Hook在您应用程序中的任何地方进行复用,或者亦可单独作为一个独立的node package发到npm。

如何使用React Hook与后端数据交互


先看一段代码,我们使用了useEffect hook,在它里面通过axios获取后端数据,然后通过setData更新数据。看起来一切很正常。

import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './App.css'

function App() {
	const [data, setData] = useState({hits: []})
	useEffect(async () => {
		const result = await axios(
			"https://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;

然而当我们真正运行这段代码时候发现,我们陷入了一个无限循环。

屏幕快照 2020-05-10 下午4.10.22.png


当组件componentDidMount时候,会执行useEffect,当我们获取到后端数据时候我们有更新state,同时组件更新,再次执行useEffect,周而复始。很明显这是一个bug我们应该避免。

事实上,我们仅仅需求在组件挂载时候执行一次useEffect。
我们只需要为useEffect提供第二个参数[]来避免组件更新时候再次执行useEffect。

但是如果我们直接在原代码基础上加一个[],仍然会报错。
屏幕快照 2020-05-10 下午4.54.06.png

useEffect必须返回一个纯函数或者没有返回值,useEffect的回调必须是同步的,以此避免出现条件竞争。因此我们可以把async放在回调内部执行。

import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './App.css'

function App() {
	const [data, setData] = useState({hits: []})
	useEffect(() => {
		const fetchQueryData = async () => {
			const result = await axios(
				"https://hn.algolia.com/api/v1/search?query=redux"
			)
			setData(result.data)
		}
		fetchQueryData()
	}, [])
	return (
		<ul>
			{data.hits.map(item => (
				<li key={item.objectID}>
					<a href={item.url}>{item.title}</a>
				</li>
			))}
		</ul>
	)
}

export default App;

useEffect的第二个参数是一个数组,里面可以存放useEffect hook依赖的变量,如果其中某个变量发生改变,那么hook会再次执行。如果我们提供的是一个空数组,当组件再次更新时候hook是不会执行的,因为它并不会watch任何依赖。

如何手动去触发一个Hook执行?

上一个demo我们相当于实现一个静默的数据获取,即组件mount时候从后端获取数据。那么现在我们再实现一个例子,通过在输入框input中进行交互实时从后端搜索对应数据。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	useEffect(() => {
		const fetchQueryData = async function() {
			const result = await axios(
				`https://hn.algolia.com/api/v1/search?query=${query}`
			)
			setData(result.data)
		}
		fetchQueryData()
	}, [])
	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

屏幕快照 2020-05-10 下午5.07.27.png

但是呢我们发现无论如何在输入框进行交互发现都不会再次调用API获取数据,因为我们忘了给useEffect传递第二个参数,把依赖传入。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	useEffect(() => {
		const fetchQueryData = async function() {
			const result = await axios(
				`https://hn.algolia.com/api/v1/search?query=${query}`
			)
			setData(result.data)
		}
		fetchQueryData()
	}, [query])
	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

完美!现在demo正常按照我们的预想在执行了,接下来我们继续想象一个常见的场景,我们提供一个按钮,通过这个按钮手动触发搜索。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	const [search, setSearch] = useState('')
	useEffect(() => {
		const fetchQueryData = async function() {
			const result = await axios(
				`https://hn.algolia.com/api/v1/search?query=${query}`
			)
			setData(result.data)
		}
		fetchQueryData()
	}, [search])
	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>
	)
}
export default App

上面的代码其实不够优雅,因为它的query和search做的似乎是一件事情,会让人产生困惑,我们可以再次改造下。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	const [url, setUrl] = useState(
		`https://hn.algolia.com/api/v1/search?query=redux`
	)
	useEffect(() => {
		const fetchQueryData = async function() {
			const result = await axios(url)
			setData(result.data)
		}
		fetchQueryData()
	}, [url])
	return (
		<Fragment>
			<input
				type="text"
				value={query}
				onChange={(event) => setQuery(event.target.value)}
			/>
			<button
				type="button"
				onClick={() =>
					setUrl(`https://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>
	)
}
export default App

可见,我们只需要能够控制useEffect的依赖,那么我们就可以控制useEffect的执行时机。

通过React Hook处理Loading


接下来继续优化下我们的交互把,当我们从后端获取数据时候显示一个Loading图。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	const [url, setUrl] = useState(
		`https://hn.algolia.com/api/v1/search?query=redux`
	)
	const [isLoading, setIsLoading] = useState(false)
	useEffect(() => {
		const fetchQueryData = async function() {
			setIsLoading(true)
			const result = await axios(url)
			setData(result.data)
			setIsLoading(false)
		}
		fetchQueryData()
	}, [url])
	return (
		<Fragment>
			<input
				type="text"
				value={query}
				onChange={(event) => setQuery(event.target.value)}
			/>
			<button
				type="button"
				onClick={() =>
					setUrl(`https://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

我们发现,当组件mount时候或者url state发生变化时候,开始执行useEffect,此时isLoading设置为true,当数据获取完毕后,isLoading为false。

通过React Hook处理异常

当我们使用React Hook与后端进行数据通信时候,如果这个过程中发生异常,那么我们应该怎么处理呢?

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	const [url, setUrl] = useState(
		`https://hn.algolia.com/api/v1/search?query=redux`
	)
	const [isLoading, setIsLoading] = useState(false)
	const [isError, setIsError] = useState(false)
	useEffect(() => {
		const fetchQueryData = async function() {
			setIsError(false)
			setIsLoading(true)
			try {
				const result = await axios(url)
				setData(result.data)
			} catch (error) {
				setIsError(true)
			}
			setIsLoading(false)
		}
		fetchQueryData()
	}, [url])
	return (
		<Fragment>
			<input
				type="text"
				value={query}
				onChange={(event) => setQuery(event.target.value)}
			/>
			<button
				type="button"
				onClick={() =>
					setUrl(`https://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

屏幕快照 2020-05-10 下午5.43.54.png

React处理Form


接下我们继续写一个常见的场景:处理表单数据。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	const [url, setUrl] = useState(
		`https://hn.algolia.com/api/v1/search?query=redux`
	)
	const [isLoading, setIsLoading] = useState(false)
	const [isError, setIsError] = useState(false)
	useEffect(() => {
		const fetchQueryData = async function() {
			setIsError(false)
			setIsLoading(true)
			try {
				const result = await axios(url)
				setData(result.data)
			} catch (error) {
				setIsError(true)
			}
			setIsLoading(false)
		}
		fetchQueryData()
	}, [url])
	return (
		<Fragment>
			<form
				onSubmit={() =>
					setUrl(`https://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>}
			{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

现在我们可以通过点击按钮或者输入Enter键来提交数据。
但是呢现在有一个问题,当我们点击提交按钮进行表单提交时候会触发浏览器的默认行为(刷新整个页面),因此我们需要处理下这个默认行为。

import React, { useState, useEffect, Fragment } from "react"
import axios from "axios"
import "./App.css"
function App() {
	const [data, setData] = useState({ hits: [] })
	const [query, setQuery] = useState('redux')
	const [url, setUrl] = useState(
		`https://hn.algolia.com/api/v1/search?query=redux`
	)
	const [isLoading, setIsLoading] = useState(false)
	const [isError, setIsError] = useState(false)
	useEffect(() => {
		const fetchQueryData = async function() {
			setIsError(false)
			setIsLoading(true)
			try {
				const result = await axios(url)
				setData(result.data)
			} catch (error) {
				setIsError(true)
			}
			setIsLoading(false)
		}
		fetchQueryData()
	}, [url])
	const submitHandle = (event) => {
		setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`)
		event.preventDefault()
	}
	return (
		<Fragment>
			<form
				onSubmit={(event) => submitHandle(event)}
			>
				<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

接下来我们从fetching data的过程中抽离出来实现一个自定义的hook,已实现代码的复用。

import { useState, useEffect } from 'react'
import axios from "axios"

const useHackerNewsApi = (initialUrl, initialData) => {
  const [data, setData] = useState(initialData)
  const [url, setUrl] = useState(initialUrl)
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)
  useEffect(() => {
		const fetchQueryData = async function () {
			setIsError(false)
			setIsLoading(true)
			try {
				const result = await axios(url)
				setData(result.data)
			} catch (error) {
				setIsError(true)
			}
			setIsLoading(false)
		}
		fetchQueryData()
  }, [url])
  return [
    {
      data,
      isLoading,
      isError
    },
    setUrl
  ]
}

export default useHackerNewsApi

import React, { useState, Fragment } from "react"
import useHackerNewsApi from './custom-hooks/hacker-news-api'
import "./App.css"
function App() {
	const [query, setQuery] = useState('redux')
	const [
		{ data, isLoading, isError },
		doFetch,
	] = useHackerNewsApi(
		"https://hn.algolia.com/api/v1/search?query=redux", 
		{hits: []}
	)
	const submitHandle = (event) => {
		doFetch(`https://hn.algolia.com/api/v1/search?query=${query}`)
		event.preventDefault()
	}
	return (
		<Fragment>
			<form
				onSubmit={(event) => submitHandle(event)}
			>
				<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,它不需要关心调用的API的是什么,它只需要对外接受外部的参数传入,以及维护好自己内部的状态如data, isLoading, isError 。同时暴露自己的方法给外面组件调用。

通过useReducer整合数据


到目前为止,我们已经使用了各种state hook来管理我们的数据,比如data, loading, error等状态。然而,我们可以发现这三个状态其实是彼此有关联,但是我们确实分散的进行管理,因此我们可以尝试使用useReducer对它们进行整合在一起。

useReducer 返回一个状态对象和一个可以改变状态对象的dispatch函数。dispatch函数接受action作为参数,action包含type和payload属性。

import { useState, useEffect, useReducer } from 'react'
import axios from "axios"

const dataFetchReducer = (state, action) => {
  console.log(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()
	}
}

const useHackerNewsApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl)
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData
  })
  useEffect(() => {
		const fetchQueryData = async function () {
			dispatch({ type: 'FETCH_INIT'})
			try {
				const result = await axios(url)
				dispatch({ type: 'FETCH_SUCCESS', payload: result.data})
			} catch (error) {
				dispatch({ type: "FETCH_FAILURE" })
			}
		}
		fetchQueryData()
  }, [url])
  return [
    state,
    setUrl
  ]
}

export default useHackerNewsApi

import React, { useState, Fragment } from "react"
import useHackerNewsApi from './custom-hooks/hacker-news-api'
import "./App.css"
function App() {
	const [query, setQuery] = useState('redux')
	const [
		{ data, isLoading, isError },
		doFetch,
	] = useHackerNewsApi(
		"https://hn.algolia.com/api/v1/search?query=redux", 
		{hits: []}
	)
	const submitHandle = (event) => {
		doFetch(`https://hn.algolia.com/api/v1/search?query=${query}`)
		event.preventDefault()
	}
	return (
		<Fragment>
			<form
				onSubmit={(event) => submitHandle(event)}
			>
				<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

现在通过action的type决定每个状态的转变,返回一个基于之前的state,以及一个可选的负载payload。例如,在一个成功的请求的情况下,有效负载被用于设置新的状态对象的数据。

中止数据请求


使用React中中还会遇到一个很常见的问题是:如果在组件中发送一个请求,在请求还没有返回的时候卸载了该组件,这个时候还会尝试设置这个状态,会报错。我们需要在hooks中处理这种情况。

有兴趣的可以看这个how to prevent setting state for unmounted components

import { useState, useEffect, useReducer } from 'react'
import axios from "axios"

const dataFetchReducer = (state, action) => {
  console.log(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()
	}
}

const useHackerNewsApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl)
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData
  })
  useEffect(() => {
    let didCancel = false
		const fetchQueryData = async function () {
			dispatch({ type: 'FETCH_INIT'})
			try {
        const result = await axios(url)
        if (!didCancel) {
          dispatch({ type: "FETCH_SUCCESS", payload: result.data })
        }
			} catch (error) {
        if (!dispatch) {
          dispatch({ type: "FETCH_FAILURE" })
        }
			}
		}
    fetchQueryData()
    return () => {
      didCancel = true
    }
  }, [url])
  return [
    state,
    setUrl
  ]
}

export default useHackerNewsApi


我们可以看到这里新增了一个didCancel变量,如果这个变量为true,不会再发送dispatch,也不会再执行设置状态这个动作。这里我们在useEffe的返回函数中将didCancel置为true,在卸载组件时会自动调用这段逻辑。也就避免了再卸载的组件上设置状态。

推荐阅读

ps: 感兴趣的可以关注波公众号。