Introduction
I was copy-pasting the same markup everywhere, tweaking a class name here, adding an error message there. In my Smart Budget Tracker app, I decided to create a reusable TextInput component that can handle different labels, error states, icons, and for the password field a show/hide toggle. now, TextInput component will rule them all at one place. This post walks through how it's structured.
Why Build a Custom TextInput?
Native elements are flexible, fine for one form, but it falls apart once more than one developer is building forms. Left unchecked, every dev styles errors differently, rebuilds the password toggle their own way. None of it is "wrong," it's just inconsistent — there's no single agreed-upon definition of how an input should behave.
Common symptoms:
Inconsistent error UI — some inputs show a red border, others just red text, others both.
Repeated password-toggle logic — rebuilt slightly differently in every form.
Accessibility gaps — aria-invalid / aria-describedby added on some forms, forgotten on others.
A shared TextInput component fixes this by becoming the one place everyone goes through — styling, error handling and accessibility are decided once and reused everywhere. Design changes become a one-line fix instead of a hunt across the codebase, and reviewers no longer need to check whether each dev remembered the details — the component already guarantees it.
Define the Props with TypeScript
I start by defining what the TextInput component accepts as props. For example, name, value, and onChange are required props that the caller must pass. The component also accepts icons, typed as React.ReactNode, meaning we can pass anything that can render
interface Props {
name: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
label?: string;
placeholder?: string;
required?: boolean;
type?: string;
disabled?: boolean;
maxLength?: number;
error?: string;
success?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
rightAction?: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
className?: string;
hint?: string;
}
useState Hook and show /hide password
This is the first hook used in the Smart Budget Tracker app, declared at the top level inside the functional component — we never call a hook conditionally. We pass false as the initial value to the showPassword state variable. isPassword is set to true when the type prop is 'password', and the following inputType determines whether the password should be shown or hidden
const [showPassword, setShowPassword] = useState(false);
const isPassword = (type === 'password');
const inputType = isPassword
? showPassword ? 'text' : 'password'
: type;
Rendering Label and action icons
We can pass JavaScript between {}, and it gets evaluated left to right. In our TextInput component, when label is passed as a prop, it checks for required and rightAction. If we pass "" or undefined, the right side of the expression won't be evaluated and nothing renders.
htmlFor links the label to the input via the id. When a user clicks the label text, the browser focuses the input.
rightAction is mainly used in places like "Forgot password?" links — it's displayed on the right side, at the same level as the label, since we're passing the class name flex justify-between items-center to position them on opposite ends of the row.
{label && (
<div className="flex justify-between items-center">
<label htmlFor={name} className="input-label">
{label}
{required && <span className="input-required">*</span>}
</label>
{rightAction && (
<span className="text-xs text-black cursor-pointer hover:text-green-700">{rightAction}</span>
)}
</div>
)}
Left Icons and Rendering the Text Input
Left icons — like the email and lock icons shown in the image — are rendered only when passed in as a prop. The actual element is then rendered with all the props received from the caller component.
{leftIcon && (
<span className="pl-3 text-slate-400 shrink-0 text-base">{leftIcon}</span>
)}
<input
id={name}
name={name}
type={inputType}
value={value}
onChange={onChange}
placeholder={placeholder}
required={required}
disabled={disabled}
maxLength={maxLength}
className={fieldCSSWrapperClass}
/>
id={name} reuses the name prop as the input's id — this is what lets htmlFor={name}on the label (from the previous section) correctly link to this exact input.
shrink-0 on the icon's span prevents the icon from getting squeezed or shrunk when the input's content is long or the container is narrow — it keeps the icon at a fixed size.
Show/Hide Password Handling
This is the part where we handle whether the password should be shown or hidden. The setter function is used to update the showPassword state variable. This block only renders when type is 'password'. When the button is clicked, it toggles between text and password — if the current type is text, the password is visible; otherwise, it's hidden.
{isPassword && (
<button
type="button"
className="input-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
>
<img src={showPassword ? eyeClosed : eyeOpen} alt={showPassword ? 'hide password' : 'show password'} width={18} height={18} />
</button>
)}
Error Handling and Hints
{error && <span className='input-error-msg'>{error}</span>}
{hint && maxLength !== undefined && value.length >= maxLength && (
<p className="input-hint-msg">{hint}</p>
)}
As shown in the image, we can pass a validation error message to the error prop. It's rendered only when this prop is passed; otherwise, it's skipped.
We can also pass a hint — but note this one only renders when three conditions are all true: hint is passed, maxLength is defined, and the current value. Length has reached or exceeded maxLength. So the hint acts as a character-limit warning rather than a general-purpose helper text, shown only once the user hits the limit
How to use it
<TextInput
label="Email Address"
name="email"
type="email"
placeholder="Sample@SmartBudget.com"
value={loginForm.email}
onChange={handleChange}
error={error}
disabled={loading}
leftIcon={<img src={emailIcon} alt="email" width={18} height={18} />}
/>
<TextInput
label="Password"
name="password"
type="password"
placeholder="********"
value={loginForm.password}
onChange={handleChange}
leftIcon={<img src={lockIcon} alt="lock" width={18} height={18} />}
rightAction="Forgot password?"
disabled={loading}
maxLength={12}
hint="Only 12 characters are allowed"
/>
What I Learned
- Reusability isn't just about saving code — it's about consistency.
- About React.ReactNode.
- The Rules of Hooks matter in practice, not just in theory.
- Short-circuit evaluation (&&) is doing a lot of quiet work.
What's Next
In my next post l'll cover how I built a login page using this reusable components
If this helped you, drop a like or comment any feedback is welcome!