Skip to main content

Tanstack React Query AsyncState Management

TST, Hongkong

Scaffolding

npm create vite@latest react-query
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
cd react-query
npm install
npm install @tanstack/react-query uuid vite-tsconfig-paths
npm install --save-dev @types/uuid

Use baseURL to be able to work with absolute imports relative to the defined base:

./tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./src",
    ...

./vite.config.js

import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths()
  ],
})

./src/views/App.tsx

import HelloWorld from 'components/HelloWorld'

import 'styles/App.css'

export default function App() {
  
  return (
    <>
          <HelloWorld greeting='Hello from React Typescript' />
    </>
  )
}

./src/components/HelloWorld.tsx

import React from 'react'

import { iHelloWorld } from 'types/interfaces'

export default function HelloWorld({ greeting }: iHelloWorld): React.JSX.Element {
    return (
        <h1>
            { greeting }
        </h1>
    )
}

./src/types/interfaces.ts

export interface iHelloWorld {
    greeting: string
}
npm run dev

Adding React Query

Wrapping the App inside the Query Provider

./src/main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

import App from 'views/App.tsx'
import 'styles/index.css'

const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={ queryClient }>
      <App />
    </QueryClientProvider>
  </React.StrictMode>,
)

Build the Todo View

./src/views/App.tsx

import { useQuery } from '@tanstack/react-query'
import { fetchTodos } from 'api'

import Greeting from 'components/HelloWorld'
import TodoCard from 'components/TodoCard'
import 'styles/App.css'

export default function App() {
  const { data: todos, isLoading } = useQuery({
    queryFn: () => fetchTodos(),
    queryKey: ["todos"]
  })

  if (isLoading) {
    return <div>Loading ...</div>
  }

  return (
    <>
      <Greeting greeting='Hello from React Typescript' />

      {todos?.map((todo) => {
        return <TodoCard key={todo.id} todo={todo} />
      })}

    </>
  )
}

Building a Mock-API

Adding a API component that allows us to fetch and add Todos that are then consumed by the view:

./src/api/index.ts

import {v4 as uuid} from 'uuid'

import { iTodo } from "types/interfaces"

const todos: iTodo[] = [
    {
        "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "title": "Do something",
        "completed": false
    }
]

export const fetchTodos = async (query=''): Promise<iTodo[]> => {
    await new Promise((resolve) => setTimeout(resolve, 1000))

    console.log("INFO :: Todos were fetched")

    const filteredTodos = todos.filter((todo) => 
        todo.title.toLowerCase().includes(query.toLocaleLowerCase())
    )

    return [... filteredTodos]
}


export const addTodo = async (todo: Pick<iTodo, 'title'>): Promise<iTodo> => {
    await new Promise((resolve) => setTimeout(resolve, 1000))

    const newTodo = {
        id: uuid(),
        title: todo.title,
        completed: false
    }

    todos.push(newTodo)

    return newTodo
}

Fetch Query

Add the Todo Template Component

./src/components/TodoCard.tsx

import React, {useState} from 'react'

import { iTodo } from 'types/interfaces'

interface TodoProps {
    todo: iTodo;
  }

export default function TodoCard({ todo }: TodoProps): React.JSX.Element {
    const [checked, setChecked] = useState(todo.completed);
  
    return (
      <div>
        {todo.title}
        <input
          type="checkbox"
          checked={checked}
          onChange={(e) => setChecked(e.target.checked)}
        />
      </div>
    );
  }

./src/styles/interfaces.ts

export interface iTodo {
    id: string
    title: string
    completed: boolean
}

Tanstack ReactQuery

Mutations

Update the Server State

Our Mock API already provides a function to add more todos. We can now use the ReactQuery state mutation to add a todo and also refresh the client side state updating the displayed list:

./src/views/App.tsx

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

import Greeting from 'components/HelloWorld'
import TodoCard from 'components/TodoCard'
import { addTodo, fetchTodos } from 'api'

import 'styles/App.css'

export default function App() {
  const queryClient = useQueryClient()
  const [title, setTitle] = useState('')

  const { data: todos, isLoading } = useQuery({
    queryFn: () => fetchTodos(),
    queryKey: ["todos"]
  })

  const { mutateAsync: addTodoMutation } = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })

  if (isLoading) {
    return <div>Loading ...</div>
  }

  return (
    <>
      <Greeting greeting='Hello from React Typescript' />

      { todos?.map((todo) => {
        return <TodoCard key={todo.id} todo={todo} />
        })
      }

      <input
        type='text'
        onChange={(e) => setTitle(e.target.value)}
        value={title}
      />

      <button onClick={ async () => {
        try {
          await addTodoMutation({ title })
          setTitle('')
        } catch (e) {
          console.log(e)
        }
      }}>
        Add Todo
      </button>

    </>
  )
}

Tanstack ReactQuery

Filter Todos

./src/views/App.tsx

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

import Greeting from 'components/HelloWorld'
import TodoCard from 'components/TodoCard'
import { addTodo, fetchTodos } from 'api'

import 'styles/App.css'

export default function App() {
  const queryClient = useQueryClient()

  const [search, setSearch] = useState('')
  const [title, setTitle] = useState('')

  const { data: todos, isLoading } = useQuery({
    queryFn: () => fetchTodos(search),
    queryKey: ["todos", {search}]
  })

  const { mutateAsync: addTodoMutation } = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })

  if (isLoading) {
    return <div>Loading ...</div>
  }

  return (
    <>
      <Greeting greeting='Hello from React Typescript' />

      { todos?.map((todo) => {
        return <TodoCard key={todo.id} todo={todo} />
        })
      }

      <input
        type='text'
        onChange={(e) => setTitle(e.target.value)}
        value={title}
      />

      <button onClick={ async () => {
        try {
          await addTodoMutation({ title })
          setTitle('')
        } catch (e) {
          console.log(e)
        }
      }}>
        Add Todo
      </button>

      <input
        type='text'
        onChange={e => (setSearch(e.target.value))}
        value={search}
      />

    </>
  )
}

Tanstack ReactQuery

Query Debounce

This works - but is very painful to use due to the simulated delay from the API response. Adding a custom hook to debounce the search input:

./src/utils/hooks.ts

import { useEffect, useState } from "react"

export const useDebounce = <T>(value: T, delay: 500) => {
    const [debounce, setDebounce] = useState<T> (value)

    useEffect( () => {
        const timeout = setTimeout(() => {
            setDebounce(value)
        }, delay)

        return () => clearTimeout(timeout)
    }, [value, delay])

    return debounce
}

./src/views/App.tsx

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

import Greeting from 'components/HelloWorld'
import TodoCard from 'components/TodoCard'
import { useDebounce } from 'utils/hooks'
import { addTodo, fetchTodos } from 'api'

import 'styles/App.css'

export default function App() {
  const queryClient = useQueryClient()

  const [title, setTitle] = useState('')
  const [search, setSearch] = useState('')
  const debounceSearch = useDebounce(search, 500)

  const { data: todos, isLoading } = useQuery({
    queryFn: () => fetchTodos(debounceSearch),
    queryKey: ["todos", {debounceSearch}]
  })

  const { mutateAsync: addTodoMutation } = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })

  if (isLoading) {
    return <div>Loading ...</div>
  }

  return (
    <>
      <Greeting greeting='Hello from React Typescript' />

      { todos?.map((todo) => {
        return <TodoCard key={todo.id} todo={todo} />
        })
      }

      <input
        type='text'
        onChange={(e) => setTitle(e.target.value)}
        value={title}
        placeholder='Todo'
      />

      <button onClick={ async () => {
        try {
          await addTodoMutation({ title })
          setTitle('')
        } catch (e) {
          console.log(e)
        }
      }}>
        Add Todo
      </button>

      <input
        type='text'
        onChange={e => (setSearch(e.target.value))}
        value={search}
        placeholder='Search'
      />

    </>
  )
}