Apr 25 2022

Do You Really Need React State to Format Inputs?

This article was originally posted on the personal site of one of our MojoTech engineers, Isaiah Thomason. The content and subject matter is relevant to the work we do here at MojoTech, and Isaiah agreed to let us cross-post the article here in it's entirety with minor edits. Enjoy.



As I've said in a previous article, it's more than possible to handle forms in React without using state. But what about formatted inputs? Let's say I have an input that's intended to take a person's phone number (or some other meaningful numeric value). You're probably used to seeing solutions that look something like this:

import React, { useState } from "react";
function MyPage() {
const [myNumericValue, setMyNumericValue] = useState("");
function handleChange(event: Event & React.ChangeEvent<HTMLInputElement>) {
const { value } = event.target;
if (/^\d*$/.test(value)) setMyNumericValue(value);
}
return (
<form>
<input id="some-numeric-input" type="text" value={myNumericValue} onChange={handleChange} />
</form>
);
}

(If you want, you can follow along in a codesandbox as you read this article. This codesandbox starts you off with the code you see above.)

Okay... This works... But this brings us back to the problem of needing state variables to handle our inputs. The problem is worse if we have several formatted inputs. If you're like me and you don't want to inherit all of the disadvantages of relying on state for your forms, there is another way...

import React from "react";
function MyPage() {
/** Tracks the last valid value of the input. */
let lastValidValue: string;
/** Helps ensure that the last valid value is maintained in the input. */
function handleBeforeInput(event: React.FormEvent<HTMLInputElement>) {
lastValidValue = (event.target as HTMLInputElement).value;
}
/** Allows the user to input correctly-formatted values. Blocks invalid inputs. */
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { value, selectionStart } = event.target;
// Invalid input value
if (!/^\d*$/.test(value)) {
event.target.value = lastValidValue;
// Keep the cursor in the right location if the user input was invalid
const cursorPlace = (selectionStart as number) - (value.length - event.target.value.length);
requestAnimationFrame(() => event.target.setSelectionRange(cursorPlace, cursorPlace));
return;
}
// Input value is valid. Synchronize `lastValidValue`
lastValidValue = value;
}
return (
<form>
<input
id="some-numeric-input"
type="text"
onBeforeInput={handleBeforeInput}
onChange={handleChange}
/>
</form>
);
}

If you're unfamiliar with the

beforeinput
event, I encourage you to check out the MDN docs. Essentially,
beforeinput
gives you access to an input's value before any changes have occurred. This is incredibly useful for keeping the
input
's value valid.

This approach works. But is it really worth it? The code is more verbose; I still have to pollute two

input
props; and this doesn't look very re-usable. Is all of that really worth it in order to avoid unnecessary re-renders, large amounts of state variables, and other problems that come with controlled inputs?

If that's what you're thinking, then you're asking the right questions. :) Thankfully, there is a solution that beautifully resolves this concern.

React Actions

I have been playing around with

recently, and I quite honestly love it. I highly encourage you to try it out for yourself. In my opinion, one of the brilliant features that
Svelte
has is actions. Actions enable you to add re-usable functionality to an HTML element without having to create a separate component. And it's all done using plain old JS functions. I first learned about actions from Kevin at Svelte Society. Kevin has a great article on Svelte actions. But you're here for React, right?

I used to think that adding re-usable functionality to HTML elements was only possible in Svelte, but it's actually still possible in React thanks to the React

ref
attribute. Check this out!

(Note: If you're unfamiliar with React refs, you should review the ref documentation before continuing.)

// components/MyPage.tsx
import React from "react";
import actFormatted from "./actions/actFormatted";
export default function MyPage() {
return (
<form>
<input ref={actFormatted(/^\d*$/)} id="some-numeric-input" type="text" />
</form>
);
}
// actions/actFormatted.ts
function actFormatted(pattern: RegExp) {
/** Stores the react `ref` */
let input: HTMLInputElement | null;
/** Tracks the last valid value of the input. */
let lastValidValue: string;
/** Helps ensure that the last valid value is maintained in the input. */
function handleBeforeInput(event: Event & { target: HTMLInputElement }): void {
lastValidValue = event.target.value;
}
/** Allows the user to input correctly-formatted values. Blocks invalid inputs. */
function handleInput(event: Event & { target: HTMLInputElement }): void {
const { value, selectionStart } = event.target;
if (!/^\d*$/.test(value)) {
event.target.value = lastValidValue;
const cursorPlace = (selectionStart as number) - (value.length - event.target.value.length);
requestAnimationFrame(() => event.target.setSelectionRange(cursorPlace, cursorPlace));
return;
}
lastValidValue = value;
}
return function (reactRef: typeof input): void {
if (reactRef !== null) {
input = reactRef;
input.pattern = pattern.toString().slice(1, -1); // Strip the leading and ending forward slashes
input.addEventListener("beforeinput", handleBeforeInput as EventListener);
input.addEventListener("input", handleInput as EventListener);
} else {
input?.removeEventListener("beforeinput", handleBeforeInput as EventListener);
input?.removeEventListener("input", handleInput as EventListener);
input = null;
}
};
}
export default actFormatted;

I call these... React Actions. (Did that give you a strong reaction? 😏)

To begin, I take advantage of the

HTMLInputElement
reference that React exposes, and I hook up all the useful handlers that I need to get the formatting job done. Because the
ref
prop that React exposes accepts a function that acts on the DOM element, I'm actually able to create this re-usable utility function that you see above. I'm even able to update meaningful HTML attributes, such as pattern!

Notice that, just as with Svelte Actions, we have to take responsibility for cleaning up the event listeners in our React Actions. According to the React docs:

React will call the

ref
callback with the DOM element when the component mounts, and call it with
null
when it unmounts. Refs are guaranteed to be up-to-date before
componentDidMount
or
componentDidUpdate
fires.

Thus, in our function, we're making sure to add the event listeners when the react reference exists (i.e., during mounting), and remove the event listeners when the

reactRef
is
null
(i.e., during unmounting).

Note: You probably noticed that this time I'm adding an

oninput
handler instead of an
onchange
handler to the input element. This is intentional, as there is a difference between
oninput
handlers and
onchange
handlers in vanilla JavaScript
. See this Stackoverflow question.

Thankfully, most well-known frontend frameworks like Vue and Svelte respect this difference; unfortunately, React does not. And since our function is using vanilla JS (not React), we have to use the regular

oninput
handler instead of an
onchange
handler (which is a good thing). This article is not intended to fully explain this React oddity, but I encourage you to learn more about it soon if you aren't familiar with it. It's pretty important.

What Are the Benefits to This Approach?

I believe this can be game changing! And for a few reasons, too!

First, it means that we don't run into the issues I mentioned in my first article about using controlled inputs. This means that we can reduce code redundancy, remove unnecessary re-renders, and maintain code and skills that are transferrable between frontend frameworks.

Second, we have a re-usable solution to our formatting problem.

Someone may say, "Couldn't we have added re-usability via components?", the answer is yes. However, in terms of re-usability, I prefer this approach over creating a custom hook or creating a re-usable component. Regarding the former, it just seems odd to use hooks for something so simple. The latter option can get you in trouble if you want more freedom over how your inputs are styled. (In addition, if you aren't using TypeScript, then redeclaring ALL the possible

HTMLInputElement
attributes as props would be a huge bother.) So much for "re-usability". Also, both of those approaches are very framework specific, and they still leave you with unnecessary re-renders in one way or another. React Actions remove the re-rendering problem without removing re-usability. They are a good option to use for re-usability and efficiency.

Third, we unblock our event handlers. What do I mean? Well, unlike Svelte, React doesn't allow you to define multiples of the same event handler on a JSX element. So once you take up a handler, that's it. Sure, you can simulate defining multiple handlers at once by doing something like this:

function MyPage() {
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
callback1(event);
callback2(event);
}
return (
<form>
<input onChange={handleChange} />
</form>
);
}

But that approach is rather bothersome β€” especially when you only want to do something as simple as format an input. By using React Actions, we've freed up that

onChange
prop! (Admittedly, it trades the
onChange
prop for the
ref
prop. However,
ref
is much less commonly used. And if you really need to use the
ref
more than once, you can get around the problem by using a similar approach to the one shown above.)

Fourth, this approach is compatible with state variables! Consider the following:

import React, { useState } from "react";
import actFormatted from "./actions/actFormatted";
export default function MyPage() {
const [value, setValue] = useState("");
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setValue(event.target.value);
}
return (
<form>
<input
ref={actFormatted(/^\d*$/)}
id="some-numeric-input"
type="text"
value={value}
onChange={handleChange}
/>
</form>
);
}

This situation acts almost exactly the same as if we were just controlling a regular input. The difference? Our

handleChange
event handler will only see the formatted value whenever the value changes. This means you can setup an event handler that's only intended to interact with the formatted value. And you can do this without needing an intermediary "re-usable component".

"But Mutations!!!"

Since this is a React article, I imagine there are a few people who might complain about how this approach includes mutations (not on state... just on

event.target
). But honestly, after playing around with some other frameworks, I've learned that there are times to mutate, and there are times not to mutate. Better to learn both and master the different situations than to impose standards impractically and make code more difficult to handle. There is a time and place for everything...

And the Possibilities Don't Stop with Inputs...

You can create whatever kind of React Action you need to get the job done for your inputs. But you can go even further! For instance, have you ever needed to make HTML Elements behave as if they're buttons? Please don't tell me you're still under the slavery of using "re-usable components":

interface ButtonLikeProps<T extends keyof HTMLElementTagNameMap>
extends React.HTMLAttributes<HTMLElementTagNameMap[T]> {
as: T;
}
function ButtonLike<T extends keyof HTMLElementTagNameMap>({
children,
as,
...attrs
}: ButtonLikeProps<T>) {
const Element = as as any;
function handleKeydown(event: React.KeyboardEvent<HTMLElementTagNameMap[T]>): void {
if (["Enter", " "].includes(event.key)) {
event.preventDefault();
event.currentTarget.click();
}
}
return (
<Element role="button" tabIndex={0} onKeyDown={handleKeydown} {...attrs}>
{children}
</Element>
);
}

Nope. Don't like it. It's a little lame that with the "re-usable component" approach, we're forced to create a prop that represents the type of element to use (whether we default its value or not). Personally, I have also found that making a clean, re-usable, flexible TypeScript interface was difficult to do without running into problems, hence the one disugsting use of

any
. (There might be a solution that works without
any
, but it wasn't worth excavating for. Try tinkering with the types and you'll see what I mean.)

We can make things much simpler with React Actions:

import React from "react";
import actFormatted from "./actions/actFormatted";
import actBtnLike from "./actions/actBtnLike";
export default function MyPage() {
return (
<form>
<input ref={actFormatted(/^\d*$/)} id="some-numeric-input" type="text" />
<label ref={actBtnLike()} htmlFor="file-upload">
Upload File
</label>
<input id="file-upload" type="file" style={{ display: "none" }} />
</form>
);
}
// actions/actBtnLike.ts
// Place this on the outside so that we don't have to define it every time an element mounts
function handleKeydown(event: KeyboardEvent & { currentTarget: HTMLElement }): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.currentTarget.click();
}
}
/** Makes an element focusable and enables it to receive `keydown` events as if it were a `button`. */
function actBtnLike() {
let element: HTMLElement | null;
return function (reactRef: typeof element): void {
if (reactRef !== null) {
element = reactRef;
element.tabIndex = 0;
element.addEventListener("keydown", handleKeydown as EventListener);
} else {
element?.removeEventListener("keydown", handleKeydown as EventListener);
element = null;
}
};
}
export default actBtnLike;

By adding a JSDoc comment, we can add some IntelliSense to our React Action so that new developers know what this function is doing! And by placing

handleKeydown
on the outside of our action, we guarantee that the function is only defined once. This will not always be possible, depending on how complex your React Action is, but I believe this is still better than having to create an unnecessary component that could potentially perform unnecessary re-renders.

In my mind, this is only the beginning. I encourage everyone who reads this article to explore the new possibilities for their React applications with this approach!

Don't Forget Your Tests!

Before wrapping up, I just wanted to make sure it was clear that actions are testable too!

// actions/__tests__/actBtnLike.test.tsx
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import actBtnLike from "../actBtnLike";
describe("Act Button-Like Action", () => {
it("Causes an element to behave like a button for keyboard events", async () => {
const handleClick = jest.fn((event: React.MouseEvent<HTMLLabelElement, MouseEvent>) => {
console.log("In a real app, the file navigator would be opened.");
});
const { getByText } = render(
<form>
<label ref={actBtnLike()} htmlFor="file-upload" onClick={handleClick}>
Upload File
</label>
<input id="file-upload" type="file" style={{ display: "none" }} />
</form>
);
// Shift focus to label element, and activate it with keyboard actions
const label = getByText(/upload file/i);
await userEvent.tab(); // Proves the element is focusable
expect(label).toHaveFocus();
await userEvent.keyboard("{Enter}");
expect(handleClick).toHaveBeenCalledTimes(1);
await userEvent.keyboard(" ");
expect(handleClick).toHaveBeenCalledTimes(2);
});
});
// actions/__tests__/actFormatted.test.tsx
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import actFormatted from "../actFormatted";
describe("Act Formatted Action", () => {
it("Enforces the specified pattern for an `input`", async () => {
const numbersOnlyRegex = /^\d*$/;
const { getByLabelText } = render(
<form>
<label htmlFor="number-input">Numeric Input</label>
<input id="number-input" ref={actFormatted(numbersOnlyRegex)} />
</form>
);
const numericInput = getByLabelText(/numeric input/i) as HTMLInputElement;
await userEvent.type(numericInput, "abc1def2ghi3");
expect(numericInput).toHaveValue("123");
});
});

(Please note that the latest beta version of

userEvent
is being used for these tests. At the time of this writing, that would be
@testing-library/user-event@14.0.0-beta.11
. When you're using v14 for your tests, remember to
await
all of your calls to the
userEvent
functions.)

Pretty straightforward. Note that, as is the case for all kinds of testing, your testing capabilities are limited to your testing tools. For instance, as noted above, version 14 of User Event Testing Library is needed to support tests for anything that relies on

beforeinput
. If you're determined to use an earlier version of the package, you'll have to run with Cypress Testing Library or something similar.

In Conclusion

And that's a wrap! Hope this was helpful! Let me know with a clap or a shoutout on Twitter maybe? πŸ˜„

I want to give a HUUUUUUUUUUGE thanks to Svelte! That is, to everyone who works so hard on that project! It really is a great framework worth checking out if you haven't done so already. I definitely would not have discovered this technique if it wasn't for them. And I want to give a special second shoutout to @kevmodrome again for the help I mentioned earlier.

I want to extend another enormous thanks to @willwill96! He caught an implementation bug in the earlier version of this article. 😬

Finally, I want to extend another big thanks to my current employer (at the time of this writing), MojoTech. ("Hi Mom!") Something unique about MojoTech is that they give their engineers time each week to explore new things in the software world and expand their skills. Typically, I learn most and fastest on side projects (when it comes to software, at least). If it wasn't for them, I probably wouldn't have been able to explore Svelte, which means I wouldn't have fallen in love with the framework and learned about actions, which means this article never would have existed. 😩

Isaiah Thomason

Share: