Recap
In part 1 of this tutorial series we set up a Tauri and create-react-app app and added a basic non-functional counter. In part 2 we created and invoked a command for incrementing our counter.
In this part, we will write a generic hook for invoking and updating hook data.
In theory this hook could be reused for both web APIs or invoked commands by
changing the underlying fetcher used by swr.
What is SWR
SWR stands for “stale while revalidate”, which is a
lightweight HTTP approach to managing requests to an API, caching data to
improve load times and fetching updates in the background. The benefits of the
react hook from the swr package are that it allows us to define queries by a
key, and then easily re-fetch data when we make changes. It also supports
optimistic UI, so in many ways is a simplified @apollo/client without
requiring a GraphQL endpoint (although it supports GraphQL).
To install SWR, first make sure that the Tauri app isn’t running. Then we can run
yarn add swr
Using the SWR library looks something like this:
const { data, error } = useSWR('my/api', fetcher)
Here the my/api bit is a key that is use to refer to the specific query, while
fetcher is some sort of wrapper over a function that calls the API to fetch
the data. In the case of a web request, it might look something like this:
const fetcher = (key: string) -> Promise<any> {
return fetch(`myapi.com/${key}`).then(r => r.json())
}
Arguments can be provided to the fetcher by passing an array to the useSWR
hook:
const { data } = useSWR(['my/api', myId], fetcher)
This argument will be passed to the fetcher, and forms part of the key that
uniquely identifies the query within the swr cache. We shouldn’t dynamically
create an object here (i.e. useSWR(['my/api', { myId }])) as this can prevent
caching.
Writing a fetcher that invokes commands
This bit is fairly straightforward. Instead of the key being the URL, here we
can just assume the key is the name of the command we want to run. While we’re
at it, lets make the invokeFetcher generic so we can have a typed response.
Create a new file, useInvoke.ts and add the following:
export const invokeFetcher = async <TArgs extends Record<string, any>, TResult>(
command: string,
args: TArgs
): Promise<TResult> => invoke<TResult>(command, args)
The invokeFetcher has two type parameters, the first TArgs defines the
arguments that are passed to the fetcher, here they must extend Record<string, any>. We also have a TResult type which determines what the invoked command
should return. We don’t attempt any error handling here, this is handled by
swr.
Using the fetcher in an swr hook
Now we can invoke a command via SWR. In our App.tsx from part
2 replace the useEffect
with:
const args = useRef({ delta: 0 })
const { data } = useSWR(['increment_counter', args.current], invokeFetcher)
useEffect(() => {
setCounter(data as number)
}, [data, setCounter])
This does two things - firstly we create a ref to hold our arguments to aid
with caching. Then we call useSWR with the name of the command and our
arguments. Finally we just hook up an effect that updates our counter state
whenever the data updates (we’ll remove this in a later step). We can also leave
the invoke command in the useCallback untouched for now.
Note that the arguments for
useSWRare in an object where the name of the fields corresponds to the arguments in the rust command function.
Refactor our commands into get/set commands
This works pretty well, but we’re kind of mixing our metaphors with the commands
when we get the inital value. For instance we’re passing a delta of 0 to get
the current value which seems a little bit weird. Lets refactor our command into
two commands - one to get the current value of the app state, and one to
increment the state by a delta. We can add the get_counter hook quite
easily:
#[tauri::command]
fn get_counter(state: State<AtomicI32>) -> Result<i32, String> {
println!("Getting counter value");
Ok(state.load(Ordering::SeqCst))
}
and we also have to make sure we register the new command:
// in the tauri::Builder in main.rs
.invoke_handler(tauri::generate_handler![increment_counter, get_counter])
We can now replace all our hooks with the following:
const { data: counter, mutate } = useSWR('get_counter', invokeFetcher)
const increment = useCallback(async () => {
const result = await invoke('increment_counter', { delta: 1 }) as number
mutate(result, false)
}, [mutate])
The useSWR hook now calls get_counter, and then inside the useCallback we
invoke the increment_counter function.
Another key difference here is we are now using a “bound mutate” function that
is returned by the useSWR hook. This lets us tell swr it should refetch the
data (in this case, invoke the get_counter command). We also pass the result
to the mutate function so that the counter variable is updated
optimistically, as well as false as a second argument to mutate. Passing false
prevents the get_counter command from being invoked again when mutate is
called. We no longer need to store the counter in state, or update when data
updates so both these hooks have been removed.
In this case our
increment_countervariable returns the value, however in some cases we may not want to do this, or perhaps we’ve updated one part of our data which means another part should be re-fetched. In this case we can omit the second argument tomutate. If you make this change and run the code you should see that both “incrementing” and “getting” actions are logged.
Write a generic hook
Lets refactor the invoke logic into a separate hook. At the bottom of
useInvoke.ts, add a new hook function:
export const useInvoke = <TArgs extends Record<string, any>, TResult>(
args: TArgs,
getCommand: string,
setCommand: string
) => {
// run the invoke command
const { data, error, mutate } = useSWR<TResult>(
[getCommand, args],
invokeFetcher
)
// create an update function
const update = useCallback(
async (newData: TResult) => {
mutate(await invoke(setCommand, { ...args }), false)
},
[args, mutate, setCommand]
)
return {
data,
fetching: !data,
error,
update,
}
}
This is a fairly standard react hook which is mostly just the code we had
previously in App.tsx moved over. We first call useSWR with our passed in
command, the arguments and the invokeFetcher. We then create a callback for
invoking the update command and cache it using useCallback. This assumes that
the invoke command also returns the updated data, if it doesn’t then we could
replace the body of the useCallback with something like:
async (newData: TResult) => {
await invoke(setCommand, { ...args })
mutate()
},
This would automatically refetch the data after the setCommand command is
invoked.
We’re assuming here that the
argsprovided to the hook is stable enough to be used as a key foruseSWR. If necessary this can be cached usinguseStateand compared whenargschanges.
We then need to update our App.tsx to use this new hook. We can replace
everything except the return with:
const { data: counter, update } = useInvoke(
defaultArgs, 'get_counter', 'increment_counter'
)
The button in the App component should be updated to use the update function
as well:
<button onClick={update}>increment</button> {counter}
When the app reloads, the counter should work as before.
Adding a second counter
To check that the hook is sharing data, we can add a second counter that uses
the same data source as the first. Add a second useInvoke hook into App.tsx
below the first:
const { data: counter2, update: update2 } = useInvoke(
defaultArgs, 'get_counter', 'increment_counter'
)
Then update our returned component:
return (
<div>
<div><button onClick={update}>increment</button> {counter}</div>
<div><button onClick={update2}>increment</button> {counter2}</div>
</div>
)
Now when the app reloads there should be two counters. Clicking either increment
button automatically updates both counters! As the args is the same for these
two useInvoke hooks, they use the same data. In the next part of this series
we’ll take a look at how we can use separate counters.
We’ve now built a generic hook that can invoke the command and manage the update logic for us. The code for this tutorial can be found here on github. Part 2 of the tutorial can be found here and part 4 of the tutorial can be found here.