Wordle in Remix: Part 6 - Handling Multiple Actions

Wordle in Remix: Part 6 - Handling Multiple Actions

·

6 min read

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!

Let me play once more

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:

  • removing the existing session cookie
  • redirecting to the /play route

Reset action

We 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.

This one's too hard

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.

Multiple actions on a single route

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.

Adding a new form component

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.

Game Board With Reset Button

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. 🙊

Button attributes

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. 🙌

Conclusion

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:

  • implementing game reset action from win and loss pop-ups
  • discussing how we can handle multiple actions on one route in Remix
  • implementing game reset action on the main game page using additional button attributes

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!

Further reading and references

Â