Wordle in Remix: Part 6 - Handling Multiple Actions

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 seventh 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 be adding protections to selected routes i...
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 sixth 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 implement resetting the game and look into how we can handle multiple actions on a single route. Let's start!
In the original Wordle game, the user can only play once per 24 hours. Our implementation will be less strict - we allow the user to reset the game at any point and play once again with a new word.
We will start by implementing the reset action on win and loss pop-ups. Resetting the game in our case means doing two things:
/play routeWe already know what's needed for handling actions in Remix - a form to trigger the action and an action handler on the server to process it. Let's go to app/routes/play/win.tsx and wrap the Play again button in a form first and change the button type. Contrary to the input form, here we don't need any input, as we won't be passing any data for that action.
// app/routes/play/win.tsx
...
export default function Win() {
...
return (
<Dialog onClose={onClose}>
<div className="text-center">
...
<form method="post">
<Button type="submit">Play again</Button>
</form>
</div>
</Dialog>
);
}
Here we used a regular HTML form element - as mentioned before, the APIs of the Remix Form and the native one are very much the same. The latter gives us all that we need for this action.
Now, let's add an action handler. We will read the session and redirect to /play page, clearing the existing cookie. You should know all that you need if you followed the previous articles.
// app/routes/play/win.tsx
import { ActionFunction, redirect } from "remix";
import { destroySession, getSession } from "~/sessions";
...
export const action: ActionFunction = async ({ request }) => {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/play", {
headers: { "Set-Cookie": await destroySession(session) },
});
};
Now, when you go to /play/win route and click Play again, it redirects you back to a clear game board and you can play again! ๐ฅณ
It works that way because when we remove the cookie and redirect to the /play route, the loader function on that route first checks whether a session exists. In our case, it does not, since we just cleared it, so we get a fresh new session cookie with a new random word.
Now, you can apply the same changes to the /play/loss route - there's no difference in the code needed there.
We also want to give the user a chance to say "Oh, I can't get this one. I have no idea what it is, so before I lose, I'd like to start again". We've got you covered - we will add a reset button on the game page as well.
Although we can render as many forms as we want on a single route, the Route Module API has a limitation that you can only define a single action function per route. On the /play route we already have an action defined, allowing the user to verify the user's guess.
We can make the Remix form submit to a different route with <Form action="/play/reset" />, but for that, we would have to implement this route just to be able to handle an action there - it wouldn't do anything else. However, there's another way we can approach it, making the existing action function handle two different actions.
Let's put in a new form component on the page, with some additional CSS classes in the wrapping elements. Note that we want to disable this form when the user hasn't made any guess yet, so there's nothing to reset.
// app/routes/play.tsx
import { ResetForm } from "~/components/form/ResetForm";
...
export default function Play() {
...
return (
<main className="my-8 mx-4">
...
<div className="flex justify-center">
<div>
<div className="mb-8 flex items-center gap-4 justify-between">
<span>
Press <Mark>Enter</Mark> to submit...
</span>
<ResetForm disabled={!resolvedGuesses.length} />
</div>
...
</div>
</div>
<Outlet />
</main>
);
}
We will add the implementation - let's create a new file with the ResetForm, which accepts a disabled prop. It will render a form with a single Reset button.
// app/components/form/ResetForm.tsx
import { Button } from "~components/Button";
interface ResetFormProps {
disabled: boolean;
}
export function ResetForm({ disabled }: ResetFormProps) {
return (
<form method="post">
<Button
type="submit"
variant="secondary"
disabled={disabled}
>
Reset
</Button>
</Form>
);
}
Once that's done, we should see the following in the browser.

If you now click on the reset button, you get an error. We just triggered the action that exists on this route. It tries to validate the guess, while the form we just submitted doesn't send any data. Ouch. ๐
We need our action handler to know what the user's intention is - whether that's a new guess submission or a reset request. HTML to the rescue (once again). ๐
We can send additional data with the form inputs by adding attributes to the button element. By adding name and value we can submit any arbitrary data when that button triggers the submission (name will be the property key and value will be the value). Let's add these two to the button.
export function ResetForm({ disabled }: ResetFormProps) {
return (
<Form method="post">
<Button
...
name="_action"
value="reset"
>
Reset
</Button>
</Form>
);
}
Now let's add the appropriate logic to the action function. When the action is reset, we want to do the same as what we did for win and loss routes - redirect to /play, clearing the session cookie. Otherwise, we continue with the guess-checking logic.
import { destroySession } from "~/sessions";
...
export const action: ActionFunction = async ({ request }) => {
const session = await getSession(request.headers.get("Cookie"));
const formData = await request.formData();
const { word, _action } = Object.fromEntries(formData);
if (_action === "reset") {
return redirect("/play", {
headers: { "Set-Cookie": await destroySession(session) },
});
}
...
};
Now, when the user clicks the button, they got another word to guess, which is what we wanted. ๐
And that's it, the user can play our game as long as they want! โจ Here's a short summary of things we've gone through:
Next up, we will be adding protections to selected routes in our application to make sure the users can't access some pages too early. 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!