Use real persistence, not useState
To useState or not to useState
The todo list is unambiguously the first frontier of the web dev. While looking at my GitHub repositories recently, I found an old todo list I created, written in React and Typescript. I laughed and almost cried while reading through the code but one thing caught my attention: the amount of React useStates
.
Considering that I know a lot more now about React hooks and persisting data, I thought to remake that application with fewer useStates
. If you are wondering about what useState is, what is it good for, and why we may want to use it carefully, keep reading.
Overview on useState
UseState is a hook provided by React to help you store and retrieve data globally within a functional component. These are the semantics:
const [something, setSomething] = useState('');
something
is the state you will be using to read the value from. In this example the statesomething
is a string that has an initial value of an empty string ('').setSomething
is a set function; you will call this function passing the new value that something will receive.useState
is the hook. You can attribute a type to this state by using<>
and adding the desired type within the angle brackets. Finally, in parentheses you can define a initial value to the state.
This is by far one of the most used hooks because we all need to handle data from the user or even from an API.
When to use useState
- When you need to store data provided by the user, i.e. the value from an input
- When you need to update data in different places within the same component. The stored values only exist within the component, you cannot access them in other components, unless you pass them as props.
When NOT to use useState
- When you need to persist data in your application. The values are stored ephemerally, meaning that if you don't provide a database, you will lose it on refresh, routing to other pages or closing the component (in case of overlay components)
- When you need to update data in different places within the same component
React's useState is a very powerful tool, but it has its limitations; overusing it may cause some performance issues and even bugs. For a full guide on useState, please read React's documentation on it.
So how can we avoid this?
First, know what type of state you want to have in your application. If it's global state for the whole application, you may consider calling your API, creating a context, or using a state management library such as Redux. If it's a local state that needs to be updated by the user or in multiple places within a functional component, using useState
is a perfect match. If it's a state that will not be updated by the user or by any other function maybe you need a useMemo
.
Back to our example, the todo list. Ideally we would need only one useState
, to get the task's title from the text input. The todos would come from the API, as well as the loading state. This is possible, as we will see in the hands-on showcase in a second. In my old application I had a useState for the loading state, one for the user input, another for the todos, and a bunch of other useStates that make no sense now.
Let's see this change with the use of Convex. I decided to use this because of their queries and mutations, that makes our lives as developers a lot easier (especially mine as a frontend-heavy developer) by enabling data fetching and manipulation in a few lines of code and real fast (like real time fast).
Hands-on showcase
So here is what we are going to do:
- Build a todo list application using Typescript, TailwindCSS, Next.js and Convex.
- Enable the following features: Add new todo, list todos and mark todo as complete.
First things first, create the project. To do that we can follow this quickstart. With the application running, we can modify the page.tsx
file in the app folder to change our layout.
I didn't do anything too crazy and stylish because that's not the purpose of this article. So the final result using Convex will look like this:
Todo application created with Next.js, Typescript, TailwindCSS and Convex
I believe the only similarities those two versions share are the frontend core tech-stack, Typescript, Next.js and TailwindCSS, and the goal, to be a to-do list, because the code looks very different. The amount of useStates in the old version is terrifying. Now let's dive into the different approaches. I will call the old one the conventional approach and the one using Convex, well... the Convex approach (so original right?).
Conventional approach
- I didn't create an API for this, instead I used the browser's localStorage and React's Context API.
- I used 4 states to manage todo list, loading state and new todo.
- Does not persist data, which is obvious because it has no database attached to it; therefore you cannot access this across multiple devices and if you close or refresh the tab by accident, I am sorry but you lose all your todos.
Convex approach
- I used convex's API to handle data storage and fetching.
- I used one
useState
to manage a new todo. - Persists data thanks to Convex, which means that if you designed your app to be mobile friendly you can carry your todo list with you to the grocery store and not miss out on any item.
Here's what all the frontend logic looks like (Link to GitHub here:
const addTodo = useMutation(api.myFunctions.addTodo);
const markTodo = useMutation(api.myFunctions.markTodo);
const todos = useQuery(api.myFunctions.listTodos, { count: 10 });
const [text, setText] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
void addTodo({ value: text });
};
const handleMark = (id: string) => {
void markTodo({ id: id });
};
addTodo
is a mutation that is called when the user hits the save button.
markTodo
is a mutation that is called when the user marks a todo as done.
And the todos is the list of all the todos retrieved by using the useQuery from Convex.
AND the one and only useState
used to build this simple yet functional todo app, the state to retrieve the todo text the user typed in the input.
You may be wondering about the backend, so under the Convex folder you may find it in the myFunctions.ts
file:
import { v } from "convex/values";
import { query, mutation, action } from "./_generated/server";
import { api } from "./_generated/api";
export const listTodos = query({
args: {
count: v.number(),
},
handler: async (ctx, args) => {
const todos = await ctx.db.query("todos").order("desc").take(args.count);
return todos.reverse();
},
});
export const addTodo = mutation({
args: {
value: v.string(),
},
handler: async (ctx, args) => {
const id = await ctx.db.insert("todos", { value: args.value, done: false });
console.log("Added new document with id:", id);
},
});
export const markTodo = mutation({
args: {
id: v.id("todos"),
},
handler: async (ctx, args) => {
const { id } = args;
const todo = await ctx.db.get(id);
if (!todo) {
throw new Error("Todo not found");
}
await ctx.db.patch(id, { done: !todo.done });
},
});
Above, we see the query to retrieve the todos and the mutation to add a new todo/mark a todo as done.
Finally, this is the database:
You can define the types in the schema.ts
file within the convex
directory. So, now let's wrap it up and answer one important question that may be in your mind right now:
Does Convex replace 100% useState?
Quick answer: No. But it really helps minimizing the number of client states, retrieving data from an API, and setting up an API. The question you must answer yourself is:
What type of state do I need to manage?
Conclusion
Congrats, now you have a new todo app to call yours and one that's much easier to maintain. Let's review what we learned:
useState
is an amazing tool that needs to be used carefully. Remember what uncle Ben said, with great power comes great responsibility.- Don't be afraid to start mapping your states; developing an app starts way before any line of code is written
- Convex mutations and queries might help you replace some of your React hook usage
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.