Async, TanStack Query, Forms, Zod
In this lesson, we'll look at asynchronous functions and how to work with them in React. Next, we'll take a look at the TanStack Query library to make working with data easier. We'll examine the Zod library and explain why we need runtime data validation. Finally, we'll look at forms and the React Hook Form library.
Async
Asynchronous programming is a key component of modern JavaScript and React, allowing efficient processing of operations that may take a long time - for example, retrieving data from an API. The oldest approach was to pass a callback that runs once the operation finishes, which nests badly as soon as one asynchronous step depends on another. ES6 (2015) introduced promises and .then() chains, and ES2017 added async/await, which is what we use today.
const fetchData = () => {
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error while loading data:', error));
};
A promise chain. This is how data was fetched between ES6 and the arrival of
async/await.
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Error while loading data:', error);
}
};
This is what the equivalent code looks like using async/await.
Understanding of asynchronous javascript
Javascript is a single-threaded language, which means it can only perform one operation at a time. The seemingly asynchronous behavior is achieved by using event loops and callbacks, which allow operations to be executed in the background and processed after completion. This concept is critical to understanding how asynchronous programming works in JS.
When javascript encounters an asynchronous operation, such as setTimeout or fetch, it passes that operation via BrowserAPI to the background where it is processed. Let's take a specific example:
function main() {
console.log('A');
setTimeout(function delayed() {
console.log('B');
}, 0);
console.log('C');
}
main();
The flow of execution of this code will be as follows:
-
The
mainfunction is moved to the call stack. -
The first line is a call to the console.log function, which is not asynchronous, so it will execute immediately and print
A. -
On the second line is a call to
setTimeout, which is asynchronous. This function uses BrowserAPI to delay thedelayedfunction call by 0ms. After this time, thedelayedfunction call will be added to the Task queue. However, this is already under the browser's control and does not block the main javascript thread, which continues without waiting for the asynchronous operation to complete. -
The third line is a call to the console.log function, which is not asynchronous, so it executes immediately and prints
C -
The
mainfunction is removed from the call stack -
The event loop detects that the call stack is empty and checks if there is any function in the task queue that should be executed. In this case, there is, so the function
delayedis added to the call stack and executed, outputtingB
This example is called non-blocking, which means that the asynchronous operation does not block the main javascript thread and allows it to continue executing the code.
Here is a great web application visualizing JS execution process.
To learn more about asynchronous programming in JS, you can check out our older materials, linked here.
Blocking example
Without using asynchronous programming, it is possible to block a javascript thread and prevent the user from interacting with the application in any way. An example of a "blocking" function is the wait function, which blocks a javascript thread for a specified number of milliseconds. If we try to click another button on the page while waiting (executing a for loop), the page will appear unresponsive.
const wait = (ms: number) => {
const start = Date.now();
while (Date.now() - start < ms) {
// blocking JS thread
}
};
export default function Home() {
return (
<>
<button
onClick={() => {
wait(2000);
}}
>
Click me to block main JS thread
</button>
<button
onClick={() => {
return console.log('Logging...');
}}
>
Click me console log
</button>
</>
);
}
In this case, clicking the Click me to block main JS thread button will block the javascript thread for 2 seconds. If we try to click the Click me to log button, the page will seemingly not respond at all.
What's going on:
-
Clicking the
Click me to block main JS threadbutton will block the javascript thread for 2 seconds. JS cannot receive other events, such as clicking another button during this time. -
Once the
Click me to logbutton is clicked, it is not possible to place this function in the event loop because it is blocked. However, the button click is not discarded but is logged to the browser's event queue. -
After 2 seconds, the
waitfunction completes and the event loop detects that there is a function in the event queue that should be executed. This function is added to the call stack and executed. This also means that if the user clicks theClick me to logbutton three times while waiting, for example, the function will also be executed 3 times despite the page appearing to be completely unresponsive.
Setting state in async functions
It is common in React that we need to set the state of a component in an asynchronous function. For example, when retrieving data from the API, we want to put the loading state to true before we start loading the data and then to false again when the data is loaded. As we know from the lesson on state, calling a setter does not change the value immediately - it schedules an update, and React batches all the updates scheduled during one task into a single re-render.
An await splits the function into exactly such tasks. Everything up to the first await runs in the task that started the function, so those updates are rendered together as soon as the function yields at the await. The code after the await continues in a later task, so the updates it schedules are rendered together in their own pass. Since React 18 this is the same automatic batching that applies everywhere else - the await does not disable it, it only marks where one batch ends and the next begins.
function Home() {
const [loading, setLoading] = useState(false);
const onClick = async () => {
setLoading(true);
console.log('JavaScript engine releases control over main thread.');
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('After 2s, the execution of onClick continues...');
setLoading(false);
};
return (
<div>
<button onClick={onClick}>Click me</button>
{loading ? 'Loading...' : 'Not loading'}
</div>
);
}
What happens step by step in this code:
-
The
onClickfunction is executed when the button is clicked. -
setLoading(true)is called first, but it does not produce an immediate rerender. It only schedules a state update. Theloadingvariable in this run ofonClickstaysfalse. -
Execution reaches the
await. At this point the function yields control, and the browser is free to do other work. -
React now processes the scheduled update, re-renders the component and the browser paints "Loading...".
-
After 2 seconds the promise resolves and the rest of
onClickcontinues in a new task.setLoading(false)schedules another update. -
The function ends, React processes that update and the browser displays "Not loading" again.
Error handling in async functions
If an error occurs in an asynchronous function, it is necessary to catch it and deal with it somehow. Usually, errors are caught using a try/catch block, which allows you to see the error and deal with it in some way.
function Home() {
const onClick = async () => {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (e) {
console.log(e);
return null;
}
};
return (
<div>
<button onClick={onClick}>Click me</button>
</div>
);
}
In this case, we just print the error to the console and return null from the function. However, we can work with the error in any way we want, for example, by displaying it to the user or logging it to the server. Without the try/catch block the promise would simply be rejected: the browser reports an unhandled rejection in the console, the user gets no feedback at all, and everything we expected to happen after the call never runs.
TanStack Query
> Powerful asynchronous state management for TS/JS
TanStack Query is a library that makes it easy to work with asynchronous data, providing data retrieval, caching, synchronization and invalidation. Most JS frameworks don't come with an opinionated way to retrieve data or how to create or update data.
Why use TanStack Query when I can get the data in useEffect and store it in a state?
If, in our application, we just need to get data from the API and display it, it is still a good idea to use TanStack Query. It doesn't matter that we don't need to use all the options like caching, polling, invalidation, retries, etc. The benefit is that we don't have to traditionally use useEffect and set the data to a state to get the data. It is straightforward to introduce a bug in this traditional approach, and if we want to treat it properly, we have to write a large amount of code that is already worthwhile to use TanStack Query.
function Home({ category }: { category: string }) {
const [data, setData] = useState([]);
const [error, setError] = useState();
useEffect(() => {
fetch(`https://api.example.com/${category}`)
.then((response) => response.json())
.then((data) => setData(data))
.catch((error) => setError(error));
}, [category]);
return (
// ...
);
}
In this case, the data acquisition looks fine at first glance. But there are +- 5 bugs!
If you want to learn about all the problems this code has, check out this blog post from the developer of TanStack Query: Why do you need React Query
Setup
After installing the library, we need to register a queryClient to be available throughout our application, and we can use the library's hooks. A QueryClient is an object on which we can define some default options, such as how long to cache the data, how often to refetch the data, how to invalidate the data, etc. We then pass this client to QueryClientProvider, making it available to all application components.
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type PropsWithChildren, useState } from 'react';
export const QueryProvider = ({ children }: PropsWithChildren) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 0,
refetchOnWindowFocus: false,
staleTime: 60 * 1000
}
}
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
Note that the client is created inside the component with useState, not next
to it at module level. In an application that renders on the server - which
ours does, as we will see with Next.js - a module-level client would be
created once per server process and therefore shared between requests and
between users. Creating it in useState gives every browser its own client,
and the initializer function makes sure it is created only on the first
render.
useQuery
A useQuery is a hook used to retrieve data from an asynchronous source, such as retrieving data via an API. This hook has several parameters, the basic one being queryKey.
Query key
The queryKey defines the key under which the retrieved data will be cached. If two components access the data under the same key, they will only realistically retrieve it once, and the other component will use the cached data. The queryKey must be an array, and the reason for this is that we can form our keys hierarchically.
For example, I have one queryKey to retrieve all users, which we define as [users]. We also have a custom queryKey for each user, which is defined as ["users", userId]. In case we need to invalidate all users, we just need to call queryClient.invalidateQueries({ queryKey: ['users'] }), which invalidates every query key that starts with ['users'], including all the keys defined as ['users', userId].
Query function
The queryFn is a function that is called when the cache data cannot be retrieved. This function should return a Promise that resolves to the data we want to retrieve. This function can be, for example, a call to fetch, or a call to some function that gets data from another source.
export default function Home({ category }: { category: string }) {
const { data, isPending, isError } = useQuery({
queryKey: ['category', category],
queryFn: async () => {
const response = await fetch(`https://api.example.com/${category}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
return data as string;
}
});
return (
// ...
);
}
In this case we have overridden the data retrieval from useEffect + useState
to use useQuery.
In an application, it is typical for multiple components to access data defined under a single queryKey. Therefore, it is customary to create a custom function for each query as a wrapper over useQuery, where all queryOptions are defined only once and all other components then access only their own hook.
async function getCategory(category: string) {
const response = await fetch(`https://api.example.com/${category}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
return data as string;
}
function useCategoryQuery(category: string) {
return useQuery({
queryKey: ['category', category],
queryFn: () => getCategory(category)
});
}
function Home({ category }: { category: string }) {
const { data, isPending, isError } = useCategoryQuery(category);
return (
// ...
);
}
We extracted queryFn into a custom function and wrapped the whole useQuery
into useCategoryQuery, which can be used by any component in the
application.
data, isPending, isError, refetch
The most commonly used properties that useQuery returns are data, isPending, isError and refetch.
-
The
datais the actual data we retrieved in thequeryFnwe defined. It is typed as whatever we return inqueryFn. It may also be undefined if the data has not yet been retrieved. -
The
isPendingandisErrorbooleans tell us if the data is still being fetched or if there was an error while getting the data. These two booleans are important for TypeScript and inferring the data type. IfisPendingis false andisErroris false as well, TypeScript will correctly narrow the data type, and we no longer need to check ifdatais undefined.
function Home({ category }: { category: string }) {
const { data, isPending, isError } = useCategoryQuery(category);
if (isPending) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error</div>;
}
return `Category: ${category}, Data: ${data}`;
}
- The
refetchfunction allows us to retrieve the data even though the data is cached. It is useful when we add a new category and need to retrieve all categories again with the newly added category. After successfully adding a category, we can callrefetchon the query, which fetches all the categories to get the new data.
Invalidation, Refetching
Two very similar principles for getting fresh data. Invalidating a query is the process of marking the data as stale and the next time the data is accessed (some component on the screen uses useQuery on the invalidated query), the data is retrieved again. Conversely, refetching is the process of retrieving data using queryFn immediately when refetch is called, despite the fact that no component currently displayed on the screen needs the data.
Invalidation or refetch is normally called via queryClient, not directly on query. In a component that is supposed to be able to invoke invalidation, we get a queryClient via useQueryClient, on which we can call invalidateQueries or refetchQueries.
function Home({ category }: { category: string }) {
const queryClient = useQueryClient();
const onInvalidate = async () => {
await queryClient.invalidateQueries({
queryKey: ['category', category],
});
};
const onRefetch = async () => {
await queryClient.refetchQueries({
queryKey: ['category', category],
});
};
return (
// ...
);
}
useMutation
Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects. For this purpose, TanStack Query exports a useMutation hook. The only required option that needs to be passed to useMutation hook is mutationFn, which is a function that can accept single parameter and returns a Promise.
async function createCategory(category: string) {
const response = await fetch('/api/category', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ category })
});
const data = await response.json();
return data as string;
}
function useCreateCategoryMutation() {
return useMutation({
mutationFn: createCategory
});
}
function Home({ category }: { category: string }) {
const mutation = useCreateCategoryMutation();
return (
<button onClick={() => mutation.mutate(category)}>Create category</button>
);
}
Again, we directly extracted useMutation into a custom hook that any
component in the application can use.
mutate, mutateAsync, isPending, isError, callbacks
The most commonly used properties that useMutation returns are mutate, mutateAsync, isPending and isError.
- The
mutatefunction allows us to callmutationFnto perform a mutation. This function can be called with a parameter that is passed tomutationFn. Notably,mutationFnmust always accept at most one parameter. In case we want to pass more parameters tomutationFn, we have to wrap them in an object.
async function createCategory({
category,
anotherParameter,
}: {
category: string;
anotherParameter: string;
}) { ... }
function useCreateCategoryMutation() { ... }
function Home({ category }: { category: string }) {
const mutation = useCreateCategoryMutation();
return (
<button
onClick={() =>
mutation.mutate({ category, anotherParameter: 'another parameter' })
}
>
Create category
</button>
);
}
- The
mutatefunction takes not only the parameters that are sent tomutationFn, but also an object with several callbacks that are called if the mutation succeeds or fails. These callbacks areonSuccessandonError. TheonSuccessis the right place to put the invalidation of another query, for example, since you know for sure that the data has been changed, and you need to reflect that in the UI. TheonErroris the right place to call the notification that something has failed, for example.
function Home({ category }: { category: string }) {
const mutation = useCreateCategoryMutation();
return (
<button
onClick={() =>
mutation.mutate(category, {
onSuccess: data => {
console.log('Category created:', data);
},
onError: error => {
console.error('Failed to create category:', error);
}
})
}
>
Create category
</button>
);
}
-
mutateAsyncis an alternative tomutate, except that here we have control over the promise returned bymutationFn. In general, it is more recommended to stick withmutatewith the proviso that we passonSuccessoronErrorcallbacks. -
The
isPendingandisErrorbooleans tell us whether a mutation is in progress or an error occurred while performing the mutation. Based on these, we can update the UI, for example, by displaying a spinner or an error notification. -
As shown above, we can pass
callbacksto themutatefunction. Setting them directly at theuseMutationhook definition level is also possible. Consequently, the way this works is that theonSuccessdefined at theuseMutationlevel is executed first, and if we define a secondonSuccessat themutatelevel, it is executed after the firstonSuccessis completed.
async function wait(ms: number) {
await new Promise(resolve =>
setTimeout(() => {
console.log('Wait finished!');
resolve('ok');
}, ms)
);
}
async function createCategory(category: string) {
// Simulate a network request
await wait(1000);
return 'ok';
}
function useCreateCategoryMutation() {
return useMutation({
mutationFn: createCategory,
onSuccess: () => {
// Necessary to return a Promise, if we want to wait for the Promise to be resolved!
return wait(1000);
}
});
}
function Home({ category }: { category: string }) {
const mutation = useCreateCategoryMutation();
return (
<button
onClick={() =>
mutation.mutate(category, {
onSuccess: () => {
console.log('onSuccess inside mutation.mutate');
}
})
}
>
Create category
</button>
);
}
In the useMutation hook, it is useful to define the invalidation of another query in onSuccess. Then in mutate it is better to define only some UI changes or component-specific things.
Zod
TypeScript helps us in application development by allowing us to define types for all our variables, functions, components, etc. This way we can expose a lot of bugs that would otherwise become apparent while the application is running.
However, TypeScript does not help us with runtime data validation, which means that if we have a variable that is supposed to be, for example, a string, but during the runtime of the application, it happens to have a number in it, TypeScript will not detect this error. In other words, if we define the result of some query as returning a string in this way:
async function getCategory(category: string) {
...
return data as string
}
We can work with the fact that the result of this query is of type string. However, if the result of this query happens to be of a different type, for example number, TypeScript will not detect this error.
Generally speaking, TypeScript does not help us check for data that arrives at runtime from external sources such as APIs or user input.
The zod library is used for exactly these purposes, specifically runtime data validation. Using this library, we can define a schema, similar to how we define types, and then validate data that comes in from various sources at runtime through this schema.
const schema = z.string();
async function getCategory(category: string) {
...
return schema.parse(data);
}
TypeScript can also efficiently infer the
schema.parse(data)result type and mark the return function as aPromise<string>type.
parse
We can call the parse function on any defined schema, passing the data we want to validate to this function. If the data matches the schema, the function returns the data; otherwise, it throws an error. An alternative is the safeParse function, which does not immediately throw an error but provides information about whether the validation was successful.
The error itself, which the parse function throws, makes it easy for the developer to read and quickly see where the error occurred in validating the data.
Uncaught ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [],
"message": "Expected string, received number"
}
]
In this case, the schema was expecting to get a string, but instead got a number.
It is possible to define arbitrarily complex types. It is also possible to create a TypeScript type from a Zod schema, which we can further use in the application. At the same time, it's linked, so if we modify the schema, the TypeScript type is also automatically modified.
const schema = z.object({
name: z.string().nullable()
});
type Schema = z.infer<typeof schema>;
/**
* type Schema = {
* name: string | null;
* }
*/
Forms
Forms are one of the essential components of most web applications. We have worked with forms during the semester but in an elementary form. There are two primary types of form - controlled and uncontrolled.
Controlled vs uncontrolled forms
A controlled form is one where we have control over all the values in the form. This means that all values in the inputs are defined by value and all changes to the values are captured by the onChange event.
function Home() {
const [name, setName] = useState('');
return (
<form
onSubmit={e => {
e.preventDefault();
console.log(name);
}}
>
<input onChange={e => setName(e.target.value)} type="text" value={name} />
<button type="submit">Submit</button>
</form>
);
}
In this example we have a controlled form: the input's value is held in a
state, and every change updates that state through the setName function.
function Home() {
return (
<form
onSubmit={e => {
e.preventDefault();
const name = e.target.name.value;
// ^--- Property 'name' does not exist on type 'EventTarget'
console.log(name);
}}
>
<input name="name" type="text" />
<button type="submit">Submit</button>
</form>
);
}
In this example, we have an uncontrolled form: the value lives in the DOM and
we only read it from the event once the form is submitted. Note that
TypeScript is not happy with this - e.target is typed as a generic
EventTarget. Typing the handler as (e: FormEvent<HTMLFormElement>) and
reading e.currentTarget.name.value instead makes it work, as we saw in the
lecture on React.
function Home() {
const nameRef = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={e => {
e.preventDefault();
const name = nameRef.current?.value;
console.log(name);
}}
>
<input ref={nameRef} type="text" />
<button type="submit">Submit</button>
</form>
);
}
This is the second example of the uncontrolled form, where we define our own
reference for <input /> using the useRef hook. We then use the onSubmit
function to pull the value from this reference.
In all cases, if we had multiple fields, we would have to perform validation over individual values, react dynamically to changes in individual field values, etc., and it would be tough to scale these solutions. That's why libraries for implementing forms were created. The most widely used one today is React Hook Form, which we will look at in this lecture; the other name worth knowing is TanStack Form, a newer library from the authors of TanStack Query and TanStack Table with first-class TypeScript inference. (You will still find plenty of tutorials recommending Formik - it is effectively unmaintained, so do not start a new project with it.)
React Hook Form
React Hook Form (RHF) helps us to reduce the amount of code along with reducing the number of unwanted rerenders. We, as developers, can decide which field needs to be rerendered and which does not. By default, no field will be rerendered, and thus, RHF works as an uncontrolled form.
After installing this library, we need to create a form instance using the useForm hook. This does not necessarily take any options. When used with TypeScript, defining the type of fields the form will contain is useful.
useForm - defaultValues, resolver
These two options are commonly used. The defaultValues option defines the default values for the fields in the form. The resolver is the function that runs when the form is submitted and validates all the fields. RHF is often used with zod, where we define a form schema using Zod and then pass this resolver as an option to useForm using the zodResolver adapter.
const formSchema = z.object({
name: z.string(),
});
type FormSchema = z.infer<typeof formSchema>;
function Home() {
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
},
});
return (
// ...
);
}
In this example, we create a form instance that we define with only one field,
name. This field has a default value of "" and will be validated using the
formSchema schema as a string.
register, handleSubmit
We need two functions from the form instance to create a basic form - handleSubmit and register.
-
The
handleSubmitfunction wraps the call to our ownonSubmitfunction. It ensures that the resolver is called before ouronSubmitis called to validate all the fields. If the validation is successful,onSubmitis called; if not, errors are displayed. -
The
registerfunction is used to register a field to a form. This function takes several parameters; the first is the name of the field we want to register, and the second is an options object. Options includerequired, which tells us that this field is required.
function Home() {
const form = useForm<FormSchema>({
// ...
});
const onSubmit = (values: FormSchema) => {
console.log(values.name);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')} />
<button type="submit">Submit</button>
</form>
);
}
FormProvider, formState, useWatch/watch, control
The FormProvider component is often used to pass all form methods to all its children via Context. If we have our own form field components, they just need to be wrapped by this Provider, and they can then access the form methods via useFormContext and other hooks that RHF provides.
The formState contains various form state information such as isDirty, isSubmitting, errors, etc.
The useWatch is a hook that allows us to track changes to the field's value. We can use this hook if we need to react to a field value change in a component. It is also important to note that the hook will re-render this field every time the field changes.
-
An interesting custom hook is this
useFormValues, which keeps track of all the form fields. If hooked, the component will update whenever any value of any field in the form changes.const useFormValues = () => { const { getValues } = useFormContext(); return { ...useWatch(), // subscribe to form value updates ...getValues() // always merge with latest form values }; };
The control in combination with the Controller component is used in case we have our own field component that we want to register in the form.
Complete form example
const formSchema = z.object({
name: z.string().min(1),
secondName: z.string().min(1)
});
type FormSchema = z.infer<typeof formSchema>;
function CustomFormInput() {
const { watch, control } = useFormContext<FormSchema>();
const name = watch('name');
return (
<Controller
control={control}
name="secondName"
render={({ field, fieldState }) => (
<>
<input
onChange={e => {
if (name === '') return;
field.onChange(e.target.value);
}}
value={field.value}
/>
{fieldState.error && <p>{fieldState.error.message}</p>}
</>
)}
/>
);
}
function Home() {
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
secondName: ''
}
});
const onSubmit = (values: FormSchema) => {
console.log(values.name);
};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')} />
<CustomFormInput />
<button type="submit">Submit</button>
</form>
</FormProvider>
);
}
Here we wrapped the entire form in a FormProvider, which allows any child to
reach the form instance through useFormContext. The custom CustomFormInput
component does not register its field with register; it uses the
Controller component together with the control object obtained from the
context. It also watches the value of name, so it can change its own
behaviour based on another field.
Next lecture
In the next lesson, we will introduce the Next.js framework and explain its essential operation, file-based routing, differences and advantages over React itself, rendering strategies, and more.
Assignment
This week, develop a 4-page application that handles user authentication, displays gifts, and allows gift creation via API interactions. Utilize tanstack-query, react-hook-form, and zod. Focus on role-specific functionalities: 'santa' can update gift statuses, while 'user' can add new gifts.