Background 背景
When I first started learning about the useActionState
hook in React 19, I understood that it dealt with the return value of React Actions. However, what didn't sit well with me about this new hook was that when I passed a function to it, the function's signature changed from taking one parameter to two, with the first being the prevState
. Additionally, we need to provide an initialValue
as the second argument to useActionState
.
当我第一次开始学习 React 19 中的 useActionState
钩子时,我了解到它处理 React 动作的返回值。然而,我对这个新钩子不太满意的地方是,当我向它传递一个函数时,该函数的签名从接受一个参数变为接受两个参数,第一个是 prevState
。此外,我们还需要为 useActionState
提供第二个参数 initialValue
。
Let’s look at an example to better understand this:
让我们通过一个例子来更好地理解这一点:
app/page.tsx
export default function Page() { const subscribe = async (formData: FormData) => { 'use server'; const email = formData.get('email')?.toString(); if (!email) { return 'Please enter a valid email'; } // persist email in database return 'Subscribed successfully'; }; return ( <form action={subscribe}> <input type='email' placeholder='Enter your email...' name='email' /> <button type='submit'>Subscribe</button> </form> ); }
The issue with this approach is that the return values from server functions are not accessible. So, useActionState
was introduced to manage return values from server functions.
该方法的缺点是服务器函数的返回值不可访问。因此,引入了 useActionState
来管理服务器函数的返回值。
Since hooks are client-specific features, we cannot colocate the server function with the form. Instead, we need to define the server function in a separate module.
由于钩子是客户端特定的功能,我们不能将服务器功能与表单放在一起。相反,我们需要在单独的模块中定义服务器功能。
The refactored version of the above example looks like this:
上面的示例重构版本看起来是这样的:
app/functions.ts
'use server'; export async function subscribe(prevState: string, formData: FormData) => { const email = formData.get('email')?.toString(); if (!email) { return 'Please enter a valid email'; } // persist email in database return 'Subscribed successfully'; }
app/page.tsx
'use client'; import { useActionState } from 'react'; import { subscribe as subscribeFn } from './functions'; export default function Page() { const subscribe = async (formData: FormData) => { 'use server'; const email = formData.get('email')?.toString(); if (!email) { return 'Please enter a valid email'; } // persist email in database return 'Subscribed successfully'; }; const [message, subscribe] = useActionState(subscribeFn, ''); return ( <> {message ? <p>{message}</p> : null} <form action={subscribe}> <input type='email' placeholder='Enter your email...' name='email' /> <button type='submit'>Subscribe</button> </form> </> ); }
As you can see, the server function's signature changes from one parameter to two, and we also need to pass an empty string as the second argument to useActionState
. In this case, the return value is a simple string
, but if you want to return something more complex, you'll have a harder time satisfying TypeScript. You'll need to do some type gymnastics to make everything work correctly.
如您所见,服务器函数的签名从单个参数变为两个,我们还需要将空字符串作为第二个参数传递给 useActionState
。在这种情况下,返回值是一个简单的 string
,但如果你想要返回更复杂的内容,满足 TypeScript 的要求会更困难。你需要进行一些类型体操,才能使一切正常工作。
useActionState
doesn't require you to use a specific framework. The function passed touseActionState
can be a server function declared with the'use server'
directive or a normal async function that runs entirely on the client without any server features.
useActionState
不需要您使用特定的框架。传递给useActionState
的函数可以是使用'use server'
指令声明的服务器函数,也可以是一个完全在客户端运行且不包含任何服务器功能的普通异步函数。
Note: Until September 2024, server functions were referred to as Server Actions. Link to PR
注意:截至 2024 年 9 月,服务器功能被称为服务器操作。PR 链接
Something Familiar 有些熟悉
Alright, let's kick off this journey by revisiting something familiar to all of us: a TODO App. Don’t worry, I’m not going to bore you with yet another typical tutorial. Instead, I’ve already built V0 of our app using a concept we’re all familiar with.
好的,让我们通过回顾我们所有人都熟悉的事物来开始这次旅程:一个待办事项应用。别担心,我不会再给你另一个典型的教程让你感到无聊。相反,我已经使用我们所有人都熟悉的概念构建了我们应用的 V0 版本。
Here’s the important piece of code for our TODO app:
这里是我们 TODO 应用程序的重要代码片段:
App.tsx
import { FormEvent, useReducer } from 'react'; import { List } from './list'; import { AddTodoForm, TodoItem } from './todo'; import { todosReducer } from './todos-reducer'; import { Todo } from './types'; const initialTodos: Todo[] = []; function App() { const [todos, dispatch] = useReducer(todosReducer, initialTodos); const handleAddTodo = (e) => { e.preventDefault(); const form = e.currentTarget; const formData = new FormData(form); const title = formData.get('title')!.toString(); const id = crypto.randomUUID(); const todo = { id, title, done: false }; dispatch({ type: 'add', payload: { todo } }); form.reset(); }; const getHandleStatusChange = (todo: Todo) => { return (done: boolean) => { const payload = { id: todo.id, updatedTodo: { ...todo, done } }; dispatch({ type: 'edit', payload }); }; }; const getHandleDelete = (todo: Todo) => { return () => { const payload = { id: todo.id }; dispatch({ type: 'delete', payload }); }; }; return ( <section> <AddTodoForm onSubmit={handleAddTodo} /> <List items={todos}> {(todo) => ( <TodoItem done={todo.done} onStatusChange={getHandleStatusChange(todo)} onDelete={getHandleDelete(todo)} > {todo.title} </TodoItem> )} </List> </section> ); }
And here’s the reducer:
并且这里是 reducer:
todos-reducer.ts
import { Todo, TodoAction } from './types'; export function todosReducer(state: Todo[], action: TodoAction) { switch (action.type) { case 'add': return [action.payload.todo, ...state]; case 'edit': return state.map((todo) => { if (todo.id === action.payload.id) { return action.payload.updatedTodo; } return todo; }); case 'delete': return state.filter((todo) => todo.id !== action.payload.id); default: throw new Error('Invalid action type'); } }
The familiar concept I’m referring to is useReducer
. Let’s take a look at its signature:
我所指的是一个熟悉的概念 useReducer
。让我们看看它的签名:
const [state, dispatch] = useReducer(reducer, initialState);
The reducer function itself looks like this:
The reducer function itself looks like this: 累加器函数本身看起来是这样的:
function reducer(state: State, action: Action): State {
// Reduces the state based on the action and returns a new state
return state;
}
Notice something? The signatures of the reducer
and useReducer
may feel familiar because they closely resemble the signatures of useActionState
and the server function we discussed earlier. Once I made this connection, half of my confusion about useActionState
was resolved 😅.
注意到了吗? reducer
和 useReducer
的签名可能感觉熟悉,因为它们与 useActionState
和之前讨论的服务器函数的签名非常相似。一旦我建立了这个联系,我对 useActionState
的困惑就解决了一半 😅。
Okay, coming back to our TODO App everything works perfectly, but there’s one issue. If you refresh the page, all the todos you’ve added disappear because we currently don’t have permanent data storage. So, let’s fix that next.
好的,回到我们的 TODO 应用,一切工作得非常完美,但有一个问题。如果你刷新页面,你添加的所有待办事项都会消失,因为我们目前没有永久数据存储。所以,我们接下来修复这个问题。
The Persistence 持久
Since we want to persist the todos in our backend (which involves calling an API endpoint, an async operation), we can’t use our reducer directly to call the API's because reducers can’t be async. The typical approach would be to call the API inside the event handler, wait for the response, and then dispatch the action with respect to the API response.
由于我们希望在我们的后端持久化待办事项(这涉及到调用 API 端点,一个异步操作),我们不能直接使用我们的 reducer 来调用 API,因为 reducers 不能是异步的。典型的方法是在事件处理程序中调用 API,等待响应,然后根据 API 响应分派动作。
However, there’s a problem with this approach. React doesn’t await your async event handlers. This can lead to all kinds of race conditions and weird bugs, which result in a poor user experience.
然而,这种方法存在一个问题。React 不会等待你的异步事件处理器。这可能导致各种竞争条件和奇怪的错误,从而影响用户体验。
A Naive Approach 一种朴素的方法
One lazy and naïve approach to fix this problem is to disable user interaction while the async operation is in progress. For example, you could prevent the user from marking a todo as done if another todo is in the process of being marked as done. Or maybe you could disable the form to add a new todo while another todo is being added. The gist is that you would block user interaction until the API response is received.
一种解决此问题的懒惰且天真的方法是,在异步操作进行时禁用用户交互。例如,你可以防止用户在另一个待办事项正在被标记为完成时标记待办事项为完成。或者,也许你可以在另一个待办事项正在添加时禁用表单以添加新的待办事项。关键在于,你将阻塞用户交互,直到收到 API 响应。
We Can Do Better
我们能做得更好
While the above approach works, it’s not ideal. Blocking user interactions for every async operation leads to a sluggish experience. Instead, we can achieve a smoother, more seamless interaction by adopting a more refined strategy. Here's how we can address these issues more effectively:
虽然上述方法可行,但并不理想。为每个异步操作阻塞用户交互会导致体验变得迟缓。相反,我们可以通过采用更精细的策略来实现更流畅、更无缝的交互。以下是更有效地解决这些问题的方法:
- State Synchronization: Keep track of ongoing async operations, showing visual feedback while the operation is pending, rather than blocking all user interactions.
状态同步:跟踪正在进行的异步操作,在操作挂起时提供视觉反馈,而不是阻止所有用户交互。 - Optimistic UI updates: Immediately update the UI to reflect the change, assuming the API call will succeed. If the call fails, you can revert the UI back to its previous state.
乐观的 UI 更新:立即更新 UI 以反映更改,假设 API 调用将成功。如果调用失败,您可以恢复 UI 到其之前的状态。
The best part is React now has built-in support to do these things natively using useActionState
.
最好的部分是 React 现在原生支持使用 useActionState
来做这些事情。
Let's update our example so that the todos are persisted and see useActionState
do the magic for us.
让我们更新我们的示例,以便待办事项可以持久化,并看看 useActionState
为我们施展魔法。
I won't be using a real backend for persisting todos. Wrapping
localStorage
in a promise doesn't sit right with me either. That's why I'll be using IndexedDB. It functions like a relational database and is async by nature. For convenience, I’ve wrapped its callback-based API with promises.
我不会使用真正的后端来持久化待办事项。将localStorage
包裹在承诺中也不符合我的感觉。这就是为什么我会使用 IndexedDB。它像关系数据库一样工作,并且本质上异步。为了方便,我已经用承诺包装了它的基于回调的 API。
App.tsx
import { FormEvent, useReducer } from 'react'; import { FormEvent, startTransition, useActionState } from 'react'; import { getTodos } from './db/queries'; import { List } from './list'; import { AddTodoForm, TodoItem } from './todo'; import { todosReducer } from './todos-reducer'; import { Todo } from './types'; const initialTodos: Todo[] = []; /* Using top level await for simplicity. Recommended to use framework provided data fetching mechanism or RSC to get the Initial Data */ const initialTodos = await getTodos(); function App() { const [todos, dispatch] = useReducer(todosReducer, initialTodos); const [todos, dispatch] = useActionState(todosReducer, initialTodos); const handleAddTodo = (e) => { e.preventDefault(); const form = e.currentTarget; const formData = new FormData(form); const title = formData.get('title')!.toString(); const id = crypto.randomUUID(); const todo = { id, title, done: false }; startTransition(() => { dispatch({ type: 'add', payload: { todo } }); }); form.reset(); }; const getHandleStatusChange = (todo: Todo) => { return (done: boolean) => { const payload = { id: todo.id, updatedTodo: { ...todo, done } }; startTransition(() => { dispatch({ type: 'edit', payload }); }); }; }; const getHandleDelete = (todo: Todo) => { return () => { const payload = { id: todo.id }; startTransition(() => { dispatch({ type: 'delete', payload }); }); }; }; return ( <section> <AddTodoForm onSubmit={handleAddTodo} /> <List items={todos}> {(todo) => ( <TodoItem done={todo.done} onStatusChange={getHandleStatusChange(todo)} onDelete={getHandleDelete(todo)} > {todo.title} </TodoItem> )} </List> </section> ); }
todos-reducer.ts
import { createTodo, deleteTodo, updateTodo } from './db/mutations'; import { Todo, TodoAction } from './types'; export function todosReducer(state: Todo[], action: TodoAction) { export async function todosReducer(state: Todo[], action: TodoAction) { switch (action.type) { case 'add': return [action.payload.todo, ...state]; const newTodo = await createTodo(action.payload.todo); return [newTodo, ...state]; case 'edit': const updatedTodo = await updateTodo( action.payload.id, action.payload.updatedTodo ); return state.map((todo) => { if (todo.id === action.payload.id) { return action.payload.updatedTodo; return updatedTodo }; return todo; }); case 'delete': const deletedId = await deleteTodo(action.payload.id); return state.filter((todo) => todo.id !== action.payload.id); return state.filter((todo) => todo.id !== deletedId); default: throw new Error('Invalid action type'); } }
Let's go over the diff and understand what has changed:
让我们查看差异并了解发生了什么变化:
-
Instead of initializing
initialTodos
with an empty array, we are now fetching the todos that are persisted in our "db" using thegetTodos
function. Here, I am using top-levelawait
for simplicity, but you should typically use the framework-provided data-fetching mechanism to get the initial data.
而不是用空数组初始化initialTodos
,我们现在使用getTodos
函数获取我们“db”中持久化的待办事项。在这里,为了简单起见,我使用了顶级await
,但你应该通常使用框架提供的数据获取机制来获取初始数据。 -
We have swapped
useReducer
withuseActionState
, and the reducer function is now async, utilizing helper functions likecreateTodo
,deleteTodo
, andupdateTodo
to perform the respective actions.
我们已经交换了useReducer
与useActionState
,并且减少器函数现在是异步的,使用createTodo
、deleteTodo
和updateTodo
等辅助函数执行相应的操作。With these changes, everything should work fine. But if you check the console, React will yell at you with the following error message:
随着这些更改,一切应该都能正常工作。但如果你检查控制台,React 会用以下错误信息向你大喊:An async function was passed to useActionState, but it was dispatched outside of an action context. This is likely not what you intended. Either pass the dispatch function to an
action
prop, or dispatch manually insidestartTransition
.
一个异步函数被传递给了 useActionState,但它是在动作上下文之外被调用的。这很可能不是你想要的。你可以将 dispatch 函数传递给action
属性,或者在内startTransition
中手动调用它。From the error message, we understand that calling our
dispatch
function directly isn’t allowed. The correct approach is to call thedispatch
function inside an Action Context, which is essentially a Transition. If we pass thedispatch
function to theaction
prop of theform
component or theformAction
prop of theinput
component provided by React, an action context will be automatically created for us.
从错误信息中,我们了解到直接调用我们的dispatch
函数是不允许的。正确的方法是在一个动作上下文中调用dispatch
函数,这本质上是一个转换。如果我们把dispatch
函数传递给form
组件的action
属性或由 React 提供的input
组件的formAction
属性,就会自动为我们创建一个动作上下文。The argument for our
dispatch
function is of typeTodoAction
. However, if we pass thedispatch
function to theaction
orformAction
prop, the argument will be of typeFormData
(automatically provided by React). Since we need to pass our own custom argument to thedispatch
function, we usestartTransition
to manually create an Action Context. This is why all thedispatch
calls are wrapped insidestartTransition
in the updated example.
我们的dispatch
函数的参数类型为TodoAction
。然而,如果我们把dispatch
函数传递给action
或formAction
属性,参数类型将变为FormData
(由 React 自动提供)。由于我们需要将自定义参数传递给dispatch
函数,我们使用startTransition
手动创建一个动作上下文。这就是为什么在更新的示例中,所有的dispatch
调用都被包裹在startTransition
中的原因。
With these very little changes, persistence is achieved!
通过这些微小的改变,坚持就实现了!
Add some Todos, play around with them, and refresh the page.
添加一些待办事项,玩弄它们,然后刷新页面。
Your Todos state is persisted! 🎉.
您的待办事项状态已持久化!🎉
If there were a real backend involved in our implementation, the behavior would be quite different. APIs might fail to respond, return error responses, and take a considerable amount of time to complete the network round trip.
如果我们的实现中真的涉及到了后端,行为将会截然不同。API 可能会无法响应,返回错误响应,并且完成网络往返需要相当长的时间。
Use the controls below to simulate these conditions and observe how the app behaves.
使用下面的控件来模拟这些条件并观察应用的行为。
When we apply some delay, you'll notice the lagging experience—it significantly hampers user experience (UX) since we don’t receive instant feedback that something is happening. Additionally, if the error control is activated, the app will crash because we’re not currently handling errors.
当我们应用一些延迟时,您会注意到卡顿的体验——由于我们没有立即收到有事情发生的反馈,这会显著影响用户体验(UX)。此外,如果激活错误控制,应用将会崩溃,因为我们目前没有处理错误。
Let’s address these issues next.
让我们接下来解决这些问题。
The Super Powers of useActionState
使用 useActionState 的超级能力
-
Pending State: 待处理状态:
If there's any action executing, we need to inform the user that something is happening in the background. You might be tempted to add another state variable to track this. Luckily, we don't have to go through that mess becauseuseActionState
does the job for us.
如果正在执行任何操作,我们需要通知用户后台正在发生某些事情。你可能想添加另一个状态变量来跟踪这个。幸运的是,我们不必经历那个混乱,因为useActionState
已经为我们完成了这项工作。It provides a third value in its return, typically called
isPending
, which istrue
when there are ongoing transitions. It automatically switches back tofalse
once all transitions within that particular Action Context are complete. (Ahh!! this feels soo good TBH 😌)
它在其返回值中提供了一个第三个值,通常称为isPending
,当有正在进行的转换时为true
。一旦该特定动作上下文中的所有转换完成,它将自动切换回false
。(啊哈!这感觉真是太好了 TBH 😌) -
State Synchronization: 状态同步:
Imagine there are three todos, and the delay is set to 2 seconds. If you attempt to mark all three todos as done, you'll notice that they all get marked at the same time, even though you clicked them sequentially. This happens becauseuseActionState
waits for all transitions to settle before updating the state, preventing intermediate flashes of unwanted content.
想象有三个待办事项,延迟设置为 2 秒。如果您尝试将所有三个待办事项标记为完成,您会注意到它们都会同时被标记,即使您是按顺序点击的。这是因为useActionState
在更新状态之前等待所有转换都稳定下来,以防止出现不希望的内容的中间闪烁。Additionally, since the reducer is asynchronous, you might expect all actions to be processed concurrently (finishing in about 2 seconds). However, it actually takes around 6 seconds—2 seconds for each action.
useActionState
processes actions sequentially, maintaining the exact order in which they were invoked. This ensures theprevState
parameter is predictable and consistent.
此外,由于 reducer 是异步的,您可能会期望所有操作都并发处理(大约 2 秒完成)。然而,实际上它需要大约 6 秒——每个操作 2 秒。useActionState
按顺序处理操作,保持它们被调用的确切顺序。这确保了prevState
参数是可预测和一致的。While this sequential processing might seem like a footgun, it’s intentional. The goal is to make sure the state updates correctly based on
prevState
. If you’re not usingprevState
properly, it’s a sign thatuseActionState
might not be suitable for your use case. Although this approach ensures accuracy, it can cause a delay in updates. We can address this using Optimistic Updates.
虽然这种顺序处理可能看起来像是一个陷阱,但这是有意为之。目标是确保根据prevState
正确更新状态。如果你没有正确使用prevState
,这可能表明useActionState
可能不适合你的用例。尽管这种方法确保了准确性,但它可能会导致更新延迟。我们可以使用乐观更新来解决这个问题。 -
Optimistic State: 乐观状态:
Instead of waiting for the server response, we can optimistically assume the user action will succeed and provide immediate feedback. If the async operation eventually succeeds, everything remains as is. If it fails, we revert to the previous state.
而不是等待服务器响应,我们可以乐观地假设用户操作将成功并提供即时反馈。如果异步操作最终成功,一切保持不变。如果失败,我们恢复到之前的状态。
To implement this, we use theuseOptimistic
hook, which integrates seamlessly withuseActionState
to manage optimistic updates effectively.
为了实现这一点,我们使用useOptimistic
钩子,它与useActionState
无缝集成,以有效地管理乐观更新。
With these concepts in mind, let's update our TODO example to implement these techniques for a more responsive and seamless user experience.
考虑到这些概念,让我们更新我们的 TODO 示例,以实现这些技术,从而提供更响应和流畅的用户体验。
App.tsx
import { FormEvent, startTransition, useActionState } from 'react'; import { FormEvent, startTransition, useActionState, useOptimistic } from 'react'; import { getTodos } from './db/queries'; import { List } from './list'; import { AddTodoForm, TodoItem } from './todo'; import { todosReducer } from './todos-reducer'; import { Todo } from './types'; /* Using top level await for simplicity. Recommended to use framework provided data fetching mechanism or RSC to get the Initial Data */ const initialTodos = await getTodos(); function App() { const [todos, dispatch] = useActionState(todosReducer, initialTodos); const [todos, dispatch, isPending] = useActionState(todosReducer, initialTodos); const [optimisticTodos, setOptimisticTodos] = useOptimistic(todos); const handleAddTodo = (e) => { e.preventDefault(); const form = e.currentTarget; const formData = new FormData(form); const title = formData.get('title')!.toString(); const id = crypto.randomUUID(); const todo = { id, title, done: false }; startTransition(() => { setOptimisticTodos((prev) => [todo, ...prev]); dispatch({ type: 'add', payload: { todo } }); }); form.reset(); }; const getHandleStatusChange = (todo: Todo) => { return (done: boolean) => { const payload = { id: todo.id, updatedTodo: { ...todo, done } }; startTransition(() => { setOptimisticTodos((prev) => prev.map((item) => (item.id === todo.id ? { ...item, done } : item)) ); dispatch({ type: 'edit', payload }); }); }; }; const getHandleDelete = (todo: Todo) => { return () => { const payload = { id: todo.id }; startTransition(() => { setOptimisticTodos((prev) => prev.filter((item) => item.id !== todo.id) ); dispatch({ type: 'delete', payload }); }); }; }; return ( <section> <p className={isPending ? 'visible' : 'invisible'}>Updates in progress...</p> <AddTodoForm onSubmit={handleAddTodo} /> <List items={todos}> <List items={optimisticTodos}> {(todo) => ( <TodoItem done={todo.done} onStatusChange={getHandleStatusChange(todo)} onDelete={getHandleDelete(todo)} > {todo.title} </TodoItem> )} </List> </section> ); }
todos-reducer.ts
import { toast } from 'your-toast-lib'; import { createTodo, deleteTodo, updateTodo } from './db/mutations'; import { Todo, TodoAction } from './types'; export async function todosReducer(state: Todo[], action: TodoAction) { try { switch (action.type) { case 'add': const newTodo = await createTodo(action.payload.todo); return [newTodo, ...state]; case 'edit': const updatedTodo = await updateTodo( action.payload.id, action.payload.updatedTodo ); return state.map((todo) => { if (todo.id === action.payload.id) { return updatedTodo; } return todo; }); case 'delete': const deletedId = await deleteTodo(action.payload.id); return state.filter((todo) => todo.id !== deletedId); default: throw new Error('Invalid action type'); } } catch (error) { if (error instanceof Error) { toast.error(error.message); } else { toast.error('Something went wrong'); } return state; } }
Let's review what has changed compared to previous implementation:
-
We are now accessing
isPending
fromuseActionState
and using it to display a message (line 61) when there are ongoing updates. This informs the user that updates are happening in the background, enhancing the user experience by providing immediate visual feedback. -
We introduced the
useOptimistic
hook, which works similarly touseState
. This hook takes a state value (in this case, the todos returned byuseActionState
) and allows us to optimistically update it.When using
useOptimistic
, we can update the state immediately before callingdispatch
, giving the user instant feedback. It's important to note that these optimistic updates must be done inside the Action Context for the hook to function properly.Once all transitions complete, the state from
useActionState
will synchronize with the optimistic state, ensuring any failed updates are automatically reverted, providing a seamless user experience. -
We wrapped the reducer logic in a
try-catch
block to handle potential errors gracefully. If an error occurs, a toast message displays the error details, and simply return the previous state. This ensures that users receive feedback when an error occurs without disrupting the app’s functionality.
Updates in progress...
Explore the final version of our TODO App and experience the combined power of useActionState
and useOptimistic
for a smooth and responsive user experience.
Closing Remarks
In this post, we transformed a simple in-memory TODO app into a robust and persistent version, significantly enhancing the user experience. We explored handling async operations, applying optimistic updates, and managing errors effectively using the useActionState
and useOptimistic
hooks.
While we’ve covered the most critical aspects, there’s always room for improvement. One of my favorite quotes reflects this perfectly:
"Just because something works, it doesn’t mean it cannot be improved."
If you notice any areas for enhancement, I’d love to hear your thoughts! Thank you for your time, and until the next post—keep improving! 😊
Oh, and by the way, the comments section below is built using the techniques we’ve covered. Feel free to experiment with it too! 😅