The hard parts of React Query

Life without React Query

const Bookmarks = ({ category }) => {
  const [data, setData] = useState([])
  const [error, setError] = useState()
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    setLoading(true);
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
	    setData(d)
      	setLoading(false)
      })
      .catch(e => setError(e))
  }, [category])
  
  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}

Life without React Query

const Bookmarks = ({ category }) => {
  const [data, setData] = useState([])
  const [error, setError] = useState()
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    setLoading(true);
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
	    setData(d)
      	setLoading(false)
      })
      .catch(e => setError(e))
  }, [category])
  
  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}
  1. Race conditions
  2. Loading state
  3. Empty state
  4. Data & Error are not reset on category change
  5. Will fire twice in StrictMode

Life without React Query

const Bookmarks = ({ category }) => {
  const [data, setData] = useState([])
  const [error, setError] = useState()
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    
    fetch(`${endpoint}/${category}`)
      .then(res => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }

        return res.json()
      })
      .then(d => {
        if (!ignore) {
          setData(d)
          setError(undefined)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
          setData(undefined)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })

    return () => {
      ignore = true
    }
  }, [category])
  
  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}

Life without React Query

While making the actual fetch request can be a pretty trivial exercise, making that state available predictably in your application is certainly not.

- React Query docs

Life with(out) React Query

const Bookmarks = ({ category }) => {
  const [data, setData] = useState([])
  const [error, setError] = useState()
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    
    fetch(`${endpoint}/${category}`)
      .then(res => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }

        return res.json()
      })
      .then(d => {
        if (!ignore) {
          setData(d)
          setError(undefined)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
          setData(undefined)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })

    return () => {
      ignore = true
    }
  }, [category])
  
  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}
import { useQuery } from '@tanstack/react-query';

const Bookmarks = ({ category }) => {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: () =>
      fetch(`${endpoint}/${category}`).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      }),
  })

  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}

Life with(out) React Query

import { useIssues } from './useIssues';

const Bookmarks = ({ category }) => {
  const { isLoading, data, error } = useIssues(category)

  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}
import { useQuery } from '@tanstack/react-query';

export const useIssues = (category) => {
  return useQuery({
    queryKey: ['bookmarks', category],
    queryFn: ({ signal }) =>
      fetch(`${endpoint}/${category}`, { signal }).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        
        return res.json()
      }),
  })
}
  1. Race conditions
  2. Loading state
  3. Empty state
  4. Data & Error are not reset on category change
  5. Will fire twice in StrictMode
  6. Error handling
import { useQuery } from '@tanstack/react-query';

const Bookmarks = ({ category }) => {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: () =>
      fetch(`${endpoint}/${category}`).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      }),
  })

  if (loading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}

Life with(out) React Query

Data Fetching is simple.

What comes afterwards is not.

Enter React Query

Nikola Mitrović

Development Lead & Technical Architect

Vega IT

Novi Sad, Serbia

Tech Speaker

What is React Query actually?

React Query is a data fetching library

Data fetching

useQuery({
  queryKey: ['issues'],
  queryFn: () => axios.get('/issues').then((response) => response.data),
})

Data fetching

useQuery({
  queryKey: ['issues'],
  queryFn: () => 
  	Promise.resolve([
      { id: '1', title: 'A Feature', status: 'closed' },
      { id: '2', title: 'A Nug', status: 'open' },
    ]),
})

How can I define a baseUrl with RQ?

How can I access response headers with RQ?

How can I make GraphQL requests with RQ?

Data fetching

React Query doesn't care.

Just return a Promise.

What is React Query actually?

React Query is an async state manager, declarative lib that help us with managing async/server state from the BE.

State Managers

function useIssues() {
  return useSelector(state => state.issues)
}
function useIssues() {
  return useStore(state => state.issues)
}

React Query as State Manager

const useIssues = () =>
  useQuery({ 
    queryKey: ['issues'], 
    queryFn: () => axios.get('/issues').then((response) => response.data) 
 })

React Query as State Manager

const useIssues = (select) =>
  useQuery({ 
    queryKey: ['issues'], 
    queryFn: () => axios.get('/issues').then((response) => response.data),
    select
 })

const useIssueCount = () {
  return useIssue((issues) => issues.length)
}

React Query as State Manager

export const useIssues = () =>
  useQuery({ 
    queryKey: ['issues'], 
    queryFn: fetchTodos 
 })

function ComponentOne() {
  const { data } = useIssues()
}

function ComponentTwo() {
  // ✅ will get exactly the same data as ComponentOne
  const { data } = useIssues()
}

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ComponentOne />
      <ComponentTwo />
    </QueryClientProvider>
  )
}

React Query as State Manager

import { useDispatch } from 'react-redux';

const dispatch = useDispatch()

const { data } = useQuery({ 
  queryKey: ['issues'], 
  queryFn: () => axios.get('/issues').then((response) => response.data),
 })

React.useEffect(() => {
  if (data) {
    dispatch(setIssues(data))
  }
}, [data])
const [issues, setIssues] = React.useState()

useQuery({ 
  queryKey: ['issues'], 
  queryFn: () => axios.get('/issues').then((response) => response.data),
  onSuccess: (data) => setIssues(data)
 })

Client v.s. server state

Server state challenges

Caching

Deduping requests

Updating stale data

Background updates

03

02

04

01

Managing memory & GC

05

06

Performance opt.

Server state challenges

Async/server state triggers

  • window focus -
  • component mount -
  • regain network -
  •                    change

refetchOnWindowFocus

refetchOnMount

refetchOnReconnect

QueryKey

Async State triggers

React Query will refetch data on every async state trigger

Async/server state triggers

  • window focus -
  • component mount -
  • regain network -
  •                    change

refetchOnWindowFocus

refetchOnMount

QueryKey

refetchOnReconnect

Smart refetches

React Query will refetch data on every async state trigger only for stale data, and staleTime default is 0

Data synchronization tool

 

  • What is correct stale time?
  • Depends on your domain & use case
  • React Query provides the means to synchronize our view with the actual data owner - the backend
  • As long as data is fresh, it will always come from the cache only

Stale time

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 2 * 60 * 1000 // 2 minutes
    }
  }
})
useQuery({
  queryKey,
  queryFn,
  staleTime: 5 * 60 * 100 // 5 minutes
})
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // ✅ globally default to 20 seconds
      staleTime: 1000 * 20,
    },
  },
})

// 🚀 everything todo-related will have
// a 1 minute staleTime
queryClient.setQueryDefaults(
  todoKeys.all,
  { staleTime: 1000 * 60 }
)

Stale time

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      refetchOnMount: false,
      refetchOnReconnect: false,
      retryOnMount: false,
      retry: 0,
      refetchInterval: 0,
    }
  }
})

Async/server state triggers

  • window focus -
  • component mount -
  • regain network -
  •                    change

refetchOnWindowFocus

refetchOnMount

QueryKey

refetchOnReconnect

Query keys as dependency params

  • Cached separately
  • Avoid race conditions
  • Automatic refetches
  • Avoid stale closure problems
const useIssues = (filters) =>
  useQuery({ 
    queryKey: ['issues', filters], 
    queryFn: () => axios
    	.get('/issues?filters=${filters}')
    	.then((response) => response.data),
 })

Query keys

Query keys

const useIssues = () => {
  const filters = useSelector((state) => state.filters)
  
  return useQuery({ 
    queryKey: ['issues', filters], 
    queryFn: () => axios.get('/issues?filters=${filters}').then((response) => response.data),
  })
}
  

Query keys

Query Key is "just" a string or serializable object

Colocate keys for better reuse

- src
  - features
    - Profile
      - index.tsx
	  - services.ts
      - queries.ts
	  - keys.ts
    - Issues
      - index.tsx
      - services.ts
      - queries.ts
	  - keys.ts

Use Query Key factories

const IssueKeys = {
  all: () => ['issues'] as const,
  lists: () => [...issueKeys.all, 'list'] as const,
  list: (filters: string) => [...issueKeys.lists(), { filters }] as const,
  details: () => [...issueKeys.all, 'detail'] as const,
  detail: (id: number) => [...issueKeys.details(), id] as const,
}
export const IssuesService = {
  getAllIssues: (filters) => {
    return axios.get('/issues?filters=${filters}').then((response) => response.data)
  }
}
import IssuesService from './services'
import IssueKeys from './keys'

const useIssues = (filters) => {
  // 🚀 get list of issues

  return useQuery({
    queryKey: IssueKeys.list(filters),
    queryFn: IssuesService.getIssues(filters),
  })
}

Use Query Key factories

// 🕺 remove everything related
// to the issue feature
queryClient.removeQueries({
  queryKey: IssueKeys.all()
})

// 🚀 invalidate all the lists
queryClient.invalidateQueries({
  queryKey: IssueKeys.lists()
})

// 🙌 prefetch a single issue
queryClient.prefetchQueries({
  queryKey: IssueKeys.detail(id),
  queryFn: IssuesService.getIssue(id),
})

// 🙌 fetch a single issue
useQuery({
  queryKey: IssueKeys.detail(id),
  queryFn: IssuesService.getIssue(id),
})
  • cache data correctly
  • refetch automatically
  • manual invalidation of cache
  • unique per query

Query keys

const useIssues = (filters) => {  
  return useQuery({ 
    queryKey: ['issues', filters], 
    queryFn: () => axios.get('/issues?filters=${filters}').then((response) => response.data),
  })
}
  
const IssuesList = (filters) => {
  const filters = useSelector((state) => state.filters)
  const { data, refetch } = useIssues(filters)
  
  
  const handleFilterChange = () => {
    refetch()
  }
  
  
  return (
  	<ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</div>
      ))}
    </ul>
  )
}

Query keys: Infinite queries

useQuery({
  queryKey: ['issues'],
  queryFn: fetchIssues,
})

// 🚨 this won't work
useInfiniteQuery({
  queryKey: ['issues'],
  queryFn: fetchInfiniteIssues,
})

// ✅ choose something else instead
useInfiniteQuery({
  queryKey: ['infiniteIssues'],
  queryFn: fetchInfiniteIssues,
})

Recap

Async State Manager

Data sync lib

Keys as factories

03

02

01

Resources

Questions

Thank you!

n.mitrovic@vegait.rs

You can find me at

Link to the slides