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
useSWR
are 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_counter
variable 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
args
provided to the hook is stable enough to be used as a key foruseSWR
. If necessary this can be cached usinguseState
and compared whenargs
changes.
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.