Skip to main content

Redux Toolkit and RTK Query

TST, Hongkong

Create Tauri app using the frontend build tool Vite.

Scaffolding

Create the Vite Frontend

npm create vite@latest rtk
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
cd rtk
npm install
npm install @reduxjs/toolkit react-redux vite-tsconfig-paths tailwindcss postcss autoprefixer
npm install --save-dev @types/react-redux @types/node

ShadCN & Tailwind

Making things pretty:

npx tailwindcss init -p

./tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },

./vite.config.ts

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

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths()
  ],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})

Run the shadcn-ui init command to setup your project:

npx shadcn-ui@latest init

✔ Would you like to use TypeScript (recommended) › yes
✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? @/styles/index.css
✔ Would you like to use CSS variables for colors? › yes
✔ Are you using a custom tailwind prefix eg. tw-? (Leave blank if not) … 
✔ Where is your tailwind.config.js located? › tailwind.config.js
✔ Configure the import alias for components: › @/components
✔ Configure the import alias for utils: › @/utils
✔ Are you using React Server Components? › no
✔ Write configuration to components.json. Proceed? › yes

And add components like:

npx shadcn-ui@latest add button card

./src/components/Counter.tsx

import { useState } from 'react'

import { Button } from '@/components/ui/button'
import {
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
  } from "@/components/ui/card"

  export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <Card>
        <CardHeader>
            <CardTitle>Counter</CardTitle>
            <CardDescription>Click the Button!</CardDescription>
        </CardHeader>
        <CardContent>
                <Button onClick={() => setCount((count) => count + 1)}>
                count is {count}
                </Button>
        </CardContent>
    </Card>
  )
}

Create the Rust Project

Now customize vite.config.ts file to get the best compatibility with Tauri:

./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()
  ],
  // prevent vite from obscuring rust errors
  clearScreen: false,
  // Tauri expects a fixed port, fail if that port is not available
  server: {
    strictPort: true,
  },
  // to access the Tauri environment variables set by the CLI with information about the current target
  envPrefix: ['VITE_', 'TAURI_PLATFORM', 'TAURI_ARCH', 'TAURI_FAMILY', 'TAURI_PLATFORM_VERSION', 'TAURI_PLATFORM_TYPE', 'TAURI_DEBUG'],
  build: {
    // Tauri uses Chromium on Windows and WebKit on macOS and Linux
    target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13',
    // don't minify for debug builds
    minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
    // produce sourcemaps for debug builds
    sourcemap: !!process.env.TAURI_DEBUG,
  }
})

Add the Tauri CLI to your project:

npm install --save-dev @tauri-apps/cli

And add it to your npm scripts:

./package.json

"scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "rust": "tauri",
    "preview": "vite preview"
  },

To scaffold a minimal Rust project that is pre-configured to use Tauri, open a terminal and run the following command:

npm run rust init

✔ What is your app name? · rusty-react-query
✔ What should the window title be? › Rusty React Query
✔ Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created? · ../dist
✔ What is the url of your dev server? › http://localhost:5173
✔ What is your frontend dev command? · npm run dev
✔ What is your frontend build command? › npm run build

Now you can run the following command in your terminal to start a development build of your app:

npm run rust dev

Tauri Bundler

The Tauri Bundler is a Rust harness to compile your binary, package assets, and prepare a final bundle.

It will detect your operating system and build a bundle accordingly. It currently supports:

  • Windows: -setup.exe, .msi
  • macOS: .app, .dmg
  • Linux: .deb, .appimage

Linux Bundle

Tauri applications for Linux are distributed either with a Debian bundle (.deb file) or an AppImage (.AppImage file). The Tauri CLI automatically bundles your application code in these formats by default. Please note that .deb and .AppImage bundles can only be created on Linux as cross-compilation doesn't work yet.

./src-tauri/tauri.conf.json

{
  ...

  "tauri": {
    
    ...

    "bundle": {
      
      ...

      "identifier": "com.instar.dev",
npm run rust build

> Error You must change the bundle identifier in `tauri.conf.json > tauri > bundle > identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.

identifier: The application identifier in reverse domain name notation (e.g. com.tauri.example). This string must be unique across applications since it is used in system configurations like the bundle ID and path to the webview data directory. This string must contain only alphanumeric characters (A–Z, a–z, and 0–9), hyphens (-), and periods (.).

./src-tauri/tauri.conf.json

"bundle": {
  ...
  "identifier": "com.instar.dev",
  ...
npm run rust build

Finished 2 bundles at:
  ./src-tauri/target/release/bundle/deb/rusty-react-query_0.1.0_amd64.deb
  ./src-tauri/target/release/bundle/appimage/rusty-react-query_0.1.0_amd64.AppImage
chmod +x ./src-tauri/target/release/bundle/appimage/rusty-react-query_0.1.0_amd64.AppImage
./src-tauri/target/release/bundle/appimage/rusty-react-query_0.1.0_amd64.AppImage

Redux Store

Hello Counter

Start by wrapping your main app component into the Redux state provider:

./main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from '@/state/store'
import App from '@/views/App'
import '@/styles/index.css'

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

The store imported here is defined in:

@/state/store.ts

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '@/state/counter/slice'

export const store = configureStore({
    reducer: {
        counter: counterReducer
    }
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

Which gives us access to a slice of our state - an initial state and reducer function for a counter:

@/state/counter/slice.ts

import { PayloadAction, createSlice } from "@reduxjs/toolkit"

import { CounterState } from "@/types/iGeneral"

const initialState: CounterState = {
    value : 0
}

const slice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        increment: (state) => {
            state.value += 1
        },
        decrement: (state) => {
            state.value -= 1
        },
        incrementByAmount: (state, action: PayloadAction<{value: number}>) => {
            state.value += action.payload.value
        },
        decrementByAmount: (state, action: PayloadAction<{value: number}>) => {
            state.value -= action.payload.value
        }
    }
})

export const { increment, decrement, incrementByAmount, decrementByAmount } = slice.actions

export default slice.reducer

With a state store and dispatch actions in place we can now implement a counter that uses the store:

@/components/Counter.tsx

import { useDispatch, useSelector } from 'react-redux'
import { Button } from "@/components/ui/button"

import {
  increment,
  decrement,
  incrementByAmount,
  decrementByAmount } from '@/state/counter/slice'
import { AppDispatch, RootState } from '@/state/store'
import '@/styles/App.css'


export default function Counter(): JSX.Element {
  const count = useSelector((state: RootState) => state.counter.value)

  const dispatch = useDispatch<AppDispatch>()

  return (
    <>
        <h1 className='text-5xl mb-7'>Count: {count}</h1>
        <div className='grid gap-4 grid-cols-4'>
            <Button onClick={() => dispatch(increment())}>
            +
            </Button>
            <Button onClick={() => dispatch(incrementByAmount({value: 10}))}>
            + 10
            </Button>
            <Button onClick={() => dispatch(decrement())}>
            -
            </Button>
            <Button onClick={() => dispatch(decrementByAmount({value: 10}))}>
            - 10
            </Button>
        </div>
    </>
  )
}

Redux Toolkit

Async API Calls

import { PayloadAction, createAsyncThunk, createSlice } from "@reduxjs/toolkit"

import { CounterState } from "@/types/iGeneral"

const initialState: CounterState = {
    value : 0
}

const slice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        increment: (state) => {
            state.value += 1
        },
        decrement: (state) => {
            state.value -= 1
        },
        incrementByAmount: (state, action: PayloadAction<{value: number}>) => {
            state.value += action.payload.value
        },
        decrementByAmount: (state, action: PayloadAction<{value: number}>) => {
            state.value -= action.payload.value
        }
    },
    extraReducers: (builder) => {
        builder
            .addCase(
                incrementAsync.pending,
                (state) => {
                    console.log(state.value)
            })
            .addCase(
                incrementAsync.fulfilled,
                (state, action: PayloadAction<number>) => {
                    state.value += action.payload
            })
    }
})


export const incrementAsync = createAsyncThunk(
    "counter/incrementAsync",
    async (value: number) => {
        await new Promise((resolve) => setTimeout(resolve, 1000))
        return value
    }
)

export const { increment, decrement, incrementByAmount, decrementByAmount } = slice.actions

export default slice.reducer
import { useDispatch, useSelector } from 'react-redux'
import { Button } from "@/components/ui/button"

import {
  increment,
  decrement,
  incrementByAmount,
  decrementByAmount,
  incrementAsync } from '@/state/counter/slice'
import { AppDispatch, RootState } from '@/state/store'
import '@/styles/App.css'


export default function Counter(): JSX.Element {
  const count = useSelector((state: RootState) => state.counter.value)

  const dispatch = useDispatch<AppDispatch>()

  return (
    <>
        <h1 className='text-5xl mb-7'>Count: {count}</h1>
        <div className='grid gap-4 grid-cols-5'>
            <Button onClick={() => dispatch(increment())}>
            +
            </Button>
            <Button onClick={() => dispatch(incrementAsync(99))}>
            Increment Async +99
            </Button>
            <Button onClick={() => dispatch(incrementByAmount({value: 10}))}>
            + 10
            </Button>
            <Button onClick={() => dispatch(decrement())}>
            -
            </Button>
            <Button onClick={() => dispatch(decrementByAmount({value: 10}))}>
            - 10
            </Button>
        </div>
    </>
  )
}

Redux Toolkit

RTK Query

Using RTK Query to ingest data from an API.

./main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'

import { ApiProvider } from '@reduxjs/toolkit/query/react'

import App from '@/views/App'
import {api} from '@/api/PokemonQuery'
import '@/styles/index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ApiProvider api={api}>
      <App />
    </ApiProvider>
  </React.StrictMode>
)

./components/api/PokemonQuery

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { iPokemonListing, iPokemonDetailData } from '@/types/iGeneral'

export const api = createApi({
  baseQuery: fetchBaseQuery({
    baseUrl: `https://pokeapi.co/api/v2/`
  }),
  endpoints: build => ({
    pokemonList: build.query<iPokemonListing, void>({
      query() {
        return {
          url: 'pokemon',
          params: {
            limit: 9
          }
        }
      }
    }),
    pokemonDetail: build.query<iPokemonDetailData, { name: string }>({
      query({name}) {
        return {
          url: 'pokemon/' + name
        }
      }
    })
  })
})

export const { usePokemonListQuery, usePokemonDetailQuery } = api

./src/views/App.tsx

import React from 'react'
import { Button } from "@/components/ui/button"

import '@/styles/App.css'
import { PokemonList, PokemonDetails } from '../components/PokemonList'

export default function App() {
    const [pokemon, setPokemon] = React.useState<string | undefined>(undefined);
  
    return (
      <>
        <header>
          <h1 className='font-sans text-5xl mb-4'>My Pokedex</h1>
        </header>
        <main>
          {pokemon ? (
            <div className='grid-cols-1 space-y-4'>
              <PokemonDetails pokemonName={pokemon} />
              <Button className='w-80' onClick={() => setPokemon(undefined)}>back</Button>
            </div>
          ) : (
            <PokemonList onPokemonSelected={setPokemon} />
          )}
        </main>
      </>
    );
  }

./src/components/PokemonList.tsx

import { usePokemonListQuery, usePokemonDetailQuery } from '@/api/PokemonQuery'
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

export function PokemonList({ onPokemonSelected }: { onPokemonSelected: (name: string) => void }) {
    const { data, isLoading, isError, isSuccess } = usePokemonListQuery()

    if (isLoading) {
      return <p>loading...</p>
    }

    if (isError) {
      return <p>Query failed</p>
    }
    
    if (isSuccess) {
      return (
        <Card className='mx-auto w-1/3'>
          <CardHeader>
            <CardTitle>Overview</CardTitle>
            <CardDescription>Pokemons</CardDescription>
          </CardHeader>
          <CardContent className='grid grid-cols-3 gap-4'>
            {data.results.map((pokemon) => (
                <Button key={pokemon.name} onClick={() => onPokemonSelected(pokemon.name)}>
                  {pokemon.name}
                </Button>
            ))}
          </CardContent>
        </Card>
      )
    }
}

export function PokemonDetails({ pokemonName }: { pokemonName: string }) {
  console.log('selected: ', pokemonName)
  const { data, isLoading, isError, isSuccess } = usePokemonDetailQuery({name: pokemonName})

  if (isLoading) {
    console.log(data)
    return <p>loading...</p>
  }

  if (isError) {
    return <p>Query failed</p>
  }
  
  if (isSuccess) {
    console.log(data)
    return (
        <Card className='mx-auto w-80'>
          <CardHeader>
            <CardTitle>{data.name}</CardTitle>
            <CardDescription>id: {data.id}</CardDescription>
          </CardHeader>
          <CardContent>
            <img src={data.sprites.front_default} className="mx-auto" alt={data.name} />
            <p>height: {data.height}</p>
            <p>weight: {data.weight}</p>
            <p>types: {data.types.map((item) => item.type.name + ' ')}</p>
          </CardContent>
        </Card>
      );
  }
}

Redux Toolkit and RTK Query

Connecting the Redux Store

Replace the API Provider with the regular Store Provider:

./main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'

import { Provider } from 'react-redux'

import App from '@/views/App'
import {store} from '@/redux/store'
import '@/styles/index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)

And use the API to update the Redux store:

./src/redux/store.ts

import { configureStore } from '@reduxjs/toolkit'

import {api} from '@/api/PokemonQuery'

export const store = configureStore({
    reducer: {
      [api.reducerPath]: api.reducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(api.middleware),
})