Redux Toolkit and RTK Query
- Related: Building a Native IP Camera Client in React
- Github Repository: React-Query Elasticsearch Client
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>
</>
)
}
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>
</>
)
}
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>
);
}
}
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),
})