Wordle in Remix: Part 9 - Validation

I help product teams build quality software and lead engineering efforts. Currently working at OpenSpace as a Senior Software Engineer.
Search for a command to run...

I help product teams build quality software and lead engineering efforts. Currently working at OpenSpace as a Senior Software Engineer.
No comments yet. Be the first to comment.
Let's create an infinite Wordle clone in Remix! ๐ฟ We will go step by step, from setting up the application to creating pages and implementing game logic using Remix, TypeScript and Tailwind.
This is the tenth and last article in a series where we create a Wordle clone in Remix! ๐ฟ We went step by step, from setting up the application to creating pages and implementing game logic. In this article, we will handle any unknown errors that mi...
Last month I built a regression-testing harness for a single library upgrade โ React Query v4 โ v5 in one of the front-end apps I'm working on. It's about 800 lines of code across four packages. It ru

Images are heavy. When you're building offline support, storing them alongside your application data is tempting โ and eventually painful. I hit this wall building support for image attachments. The n

Deciding how to approach offline support can be challenging, especially once you move beyond the basics. So far, I focused on persisting data that was already fetched as a result of user actions. This

Software engineering is changing faster than most of us can comfortably adapt to. The capabilities of LLM models change almost monthly. New tools appear every week. Advice that felt solid three months

This is the eighth post in my series about offline support in web applications and the fifth focused on the foreground queue. In the previous article, we covered error handling and retry strategies. A

This is the ninth article in a series where we create a Wordle clone in Remix! ๐ฟ We go step by step, from setting up the application to creating pages and implementing game logic. In this article, we will try to prevent the most frequently occurring errors in our game by adding server validation and proper alerts in the browser. Let's go!
We've got a bunch of smaller problems which you probably noticed during the development. Here's a short summary of those.
Let's take these issues one by one and implement the necessary validation. โ
We will start by adding the validation on the backend side. When a user submits an empty guess, we want to return earlier with some message for the user. Also, since this is an error, we might want to indicate that by changing the status code to 400.
Up until this point, we were only using redirect in action handlers but we can also use json, the same as in loaders, to return error data with the correct status. Let's give that a go!
// app/routes/play.tsx
...
export const action: ActionFunction = async ({ request }) => {
...
if (_action === "reset") {
return redirect("/play", {
headers: { "Set-Cookie": await destroySession(session) },
});
}
if (word === "") {
return json(
{ error: "Guess cannot be empty" },
{
status: 400,
headers: { "Set-Cookie": await commitSession(session) },
}
);
}
...
};
Now, when you submit an empty guess and look into the Network tab in your browser, you should see a rejected request with the error message, just like so.

While it's great that we can see the error in the Network tab, the users would probably appreciate it more if we displayed the message on the interface instead - let's do that. ๐
Similar to reading the data that's returned from the loader function with useLoaderData, we can read the data returned from the action function with useActionData hook from Remix. Based on that, we can extend the status of our view.
// app/routes/play.tsx
...
export default function Play() {
...
const actionData = useActionData();
const status = transition.submission
? "loading"
: actionData?.error
? "error"
: "idle";
...
}
Contrary to useLoaderData which, if a loader is defined for a route, always returns data, useActionData only gives us something when the user has used that action - that's why we need to check if the return value of that hook is defined.
With that in place, when we're in the error state, let's display a DismissableAlert with the message (like always, here's the implementation of the component).
// app/routes/play.tsx
import { DismissableAlert } from "~/components/DismissableAlert";
...
export default function Play() {
...
return (
<main className="my-8 mx-4">
...
<div className="flex justify-center">
<div>
...
{status === "error" && (
<div className="mt-8">
<DismissableAlert status="error">
{actionData.error}
</DismissableAlert>
</div>
)}
</div>
</div>
...
</main>
);
}
Now when you submit an empty guess, there's a nice alert displayed for the user.

You can dismiss it by clicking the close button. Also, because it's only rendered when we are in the error state, it goes away when you submit an actual word. Awesome! ๐
In this case, we can also introduce an early validation in the browser. We might want to skip submitting the form when the input is empty. Let's do that - once again we rely on native HTML Form API and prevent the submission if a user hasn't typed in the word.
// app/components/form/InputForm.tsx
...
export function InputForm({ input, setInput, disabled }: InputFormProps) {
...
return (
<Form
method="post"
autoComplete="off"
className="h-0 overflow-hidden"
onSubmit={(e) => {
if (input.length === 0) {
e.preventDefault();
}
}}
>
...
</Form>
);
}
With that, when the input is empty and we hit enter, we no longer get an error. Even though the server validation is no longer used, we might want to leave it in place for completeness.
Let's now handle the remaining two issues, shall we?
First, let's return an error when the submitted word is shorter than 5 letters. For completeness and full type safety, we will also return an error when a submitted word is not a string.
// app/routes/play.tsx
...
export const action: ActionFunction = async ({ request }) => {
...
if (typeof word !== "string") {
return json(
{ error: "Guess must be of type string" },
{
status: 400,
headers: { "Set-Cookie": await commitSession(session) },
}
);
}
if (word === "") {
return json(
{ error: "Guess cannot be empty" },
{
status: 400,
headers: { "Set-Cookie": await commitSession(session) },
}
);
}
if (word.length !== 5) {
return json(
{ error: "Guess must be of length 5" },
{
status: 400,
headers: { "Set-Cookie": await commitSession(session), },
}
);
}
...
}
We should see the following when we submit a word that is shorter than 5 letters.

One last thing to check is whether the submitted word is valid. The words.ts module exposes a function that verifies if the word exists in English.
Disclaimer: we are using a very naive and simplified implementation including a hardcoded list of words, just for development purposes. You might want to extend this list to contain more valid words or change the implementation to use some open dictionary API to verify the word.
Let's use this function as the last step of our validation.
// app/routes/play.tsx
import { inWordList } from "~/words";
...
export const action: ActionFunction = async ({ request }) => {
...
if (!inWordList(word)) {
return json(
{ error: "Guess is not in word list" },
{
status: 400,
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
}
...
};
Let's try submitting a string that's not a valid word in English.

Just what we'd expect, great! ๐ฅณ
And this is it, we've added validation to our application and surfaced errors that might occur to the user. โจ Here's a short summary of things we've gone through:
useActionData to handle the returned errors on the client-side and error alertsWe are really close to wrapping up the entire project - in the last article, we will handle other, uncaught errors that might happen in our application. See you! ๐
If you liked the article or you have a question, feel free to reach out to me on Twitterโ or add a comment below!