Context, Async, Tanstack Query, Forms, Zod
In this lesson, we will briefly return to Context to review how it works and how it is used. Then, 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.
Context
Why global state
While having components self-sufficient and not relying on their context is great, there are cases where you need to share some data across your whole application. Some examples may include user preferences, your app theme, translations, or backend data cache.
Prop drilling
Let's say that our application must be translated into English and Czech. The whole application has to know whether to output Czech or English texts. Last but not least, we need a component allowing the user to switch between languages. If we did not use Context or other global state, we would have to pass the language information to all components that need it. This would mean that we would have to pass prop language
from the root component to those that need it. This problem is called prop drilling and is one of the reasons why the global state is used.

createContext, Context.Provider, useContext
Three basic functions you need to know to use Context.
type User = {
name: string;
age: number;
};
type UserContextType = {
user: User;
setUser: (user: User) => void;
};
// create context
const UserContext = createContext<UserContextType | null>(null);
// define custom useUserContext hook
function useUserContext() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUserContext must be used within a UserProvider');
}
return context;
}
// define custom Context.Provider wrapper - UserProvider
function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User>({ name: 'John', age: 30 });
const value = useMemo(
() => ({
user,
setUser
}),
[user]
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function UserDetails() {
// Thanks to custom useUserContext hook, we can be sure
// that context is defined. It would throw an error if
// we used UserDetails component outside UserProvider.
const { user, setUser } = useUserContext();
return (
<div>
<h1>User Details</h1>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<button onClick={() => setUser({ name: 'Jane', age: 25 })}>
Change User
</button>
</div>
);
}
export default function Home() {
return (
<UserProvider>
<UserDetails />
</UserProvider>
);
}
Multiple context providers
It is common for an application to need to define multiple global states. It is also the case that a library requires its context provider to be defined, and at the same time, we need to create our global context provider within the application. In this case, for clarity, it is useful to create a custom component that defines all global providers and then use this one provider in our application in the root component.
function AppProvider({ children }: PropsWithChildren) {
return (
<SomeComponentLibraryProvider>
<UserProvider>
<SettingsProvider>{children}</SettingsProvider>
</UserProvider>
</SomeComponentLibraryProvider>
);
}
Interestingly, for example, the SettingsProvider
component can use the
context from UserProvider
. We can use AppProvider
in the root component of
our application, with the rest of our application as children
.
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. Traditionally, callbacks were used in JS; now, async/await is used.
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));
};
This is what data retrieval looked like before the advent of ES6 and 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
main
function 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 thedelayed
function call by 0ms. After this time, thedelayed
function 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
main
function 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
delayed
is 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 thread
button 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 log
button 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
wait
function 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 log
button 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, if we call setState
inside a function, the state change is not triggered immediately but only after the entire function is complete.
However, this is different for asynchronous functions. If we call setState
in an asynchronous function, the state change is performed before the first occurrence of await
. After the asynchronous function is complete, we can call setState
again, which is executed after the entire function is complete. That is, all state changes that are called before the first await
are executed before this await
, and all state changes that are called after the await
are executed after the entire function has been completed.
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
onClick
function is executed when the button is clicked. -
There is first a call to the
setLoading(true)
function, but that does not produce an immediate rerender. It only schedules the update state of the component. -
Execution of the function hits
await
.At this point, the JS engine releases control of the thread, allowing the browser to execute other tasks, such as updating the UI. -
One UI update is scheduled thanks to
setLoading(true)
, which was called beforeawait
. So now the rerender takes place, and the browser displays "Loading...". -
After 2 seconds have passed, the
onClick
function execution returns to the event loop and continues. NowsetLoading(false)
is called, which again schedules the component state update. -
Since this ends the
onClick
function, the rerender takes place and displays "Not loading" in the browser.
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. If we didn't use the try/catch block, the error would be displayed in the console, causing the whole application to crash.
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';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
refetchOnWindowFocus: false,
staleTime: Infinity
}
}
});
const App = () => (
<QueryClientProvider client={queryClient}>
<Home />
</QueryClientProvider>
);
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(["users"])
, which invalidates all queryKeys that start with ["users"]
, including all queryKeys that are 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, isLoading, 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, isLoading, 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
, isLoading
, isError
and refetch
.
-
The
data
is the actual data we retrieved in thequeryFn
we defined. They are typed as what we return inqueryFn
. They may also be undefined if the data has not yet been retrieved. -
The
isPending
,isError
are booleans that tell us if the data is already available or if there was an error while getting the data. These two booleans are important for typescript and inferring the data type. IfisPending
is false andisError
is also untrue, typescript will correctly infer the data type, and we no longer need to check ifdata
is 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
refetch
function 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 callrefetch
on 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
mutate
function allows us to callmutationFn
to perform a mutation. This function can be called with a parameter that is passed tomutationFn
. Notably,mutationFn
must 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
mutate
function 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 areonSuccess
andonError
. TheonSuccess
is 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. TheonError
is 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>
);
}
-
mutateAsync
is an alternative tomutate
, except that here we have control over the Promis that returnsmutationFn
. In general, it is more recommended to stick withmutate
with the proviso that we passonSuccess
oronError
callbacks. -
The
isPending
andisError
booleans 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
callbacks
to themutate
function. Setting them directly at theuseMutation
hook definition level is also possible. Consequently, the way this works is that theonSuccess
defined at theuseMutation
level is executed first, and if we define a secondonSuccess
at themutate
level, it is executed after the firstonSuccess
is 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: () => {
// Neccessary to return 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 where we have control over the
name
value in <input />
. We hold the value in a state where we update this state every time we change
it using 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 where we have no control over
the name
value in <input />
. We retrieve the value via event after submitting the form. As we can see, without
proper event typing, TypeScript will not be happy with this entry
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, where the most popular ones are Formik and React Hook Form. In this tutorial, we will be looking at React Hook Form.
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
function is when the form is submitted and validates all 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
from
schema as a string.
register, handleSubmit
We need two functions from the form
instance - handleSubmit
and register` to create a basic form.
-
The
handleSubmit
function wraps the call to our ownonSubmit
function. It ensures that the resolver is called before ouronSubmit
is called to validate all the fields. If the validation is successful,onSubmit
is called; if not, errors are displayed. -
The
register
function 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>
);
}
In this example, they wrapped the entire form in a FormProvider
, which
allowed the use of hooks like useFormContext
. We also created a custom
CustomInput
component that does not register the field using register
, but
uses a Controller
component and a control
component that we got from
useFormContext
. In CustomInput
we also added watch
to the value of
name
, whereby we can change the component's behavior based on the value of
name
.
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.