Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-uncontrolled-form-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lambdacurry/forms": patch
---

Add uncontrolled mode to FormError component via a manual `message` prop.
79 changes: 79 additions & 0 deletions apps/docs/src/remix-hook-form/form-error-uncontrolled.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { FormError } from '@lambdacurry/forms';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RemixFormProvider, useRemixForm } from 'remix-hook-form';
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';

const UncontrolledFormErrorExample = ({ message }: { message?: string }) => {
const methods = useRemixForm({
defaultValues: {
email: '',
},
});

return (
<RemixFormProvider {...methods}>
<div className="max-w-md mx-auto p-6 space-y-4 border rounded-lg shadow-sm">
<h2 className="text-xl font-semibold text-gray-900">Uncontrolled Error</h2>
<p className="text-sm text-gray-500 mb-4">
This FormError is rendered with a manual message prop, bypassing the form state.
</p>

<FormError message={message} />

<div className="p-4 bg-gray-50 rounded-md border border-gray-200">
<p className="text-sm text-gray-600">
The error above is not coming from <code>errors._form</code>. It's passed directly as a prop.
</p>
</div>
</div>
</RemixFormProvider>
);
};

const meta: Meta<typeof FormError> = {
title: 'RemixHookForm/FormError/Uncontrolled',
component: FormError,
parameters: {
layout: 'centered',
docs: {
description: {
component: `
The FormError component can be used in an "uncontrolled" mode by passing a \`message\` prop directly.
This is useful for displaying errors that aren't managed by the form's validation state, such as generic network errors or manual UI feedback.

**Key Features of Uncontrolled Mode:**
- Takes precedence over form-state errors
- Does not require a \`name\` prop
- Maintains consistent styling and accessibility
`,
},
},
},
tags: ['autodocs'],
decorators: [
withReactRouterStubDecorator({
routes: [
{
path: '/',
Component: () => <UncontrolledFormErrorExample message="This is a manual, uncontrolled error message." />,
},
],
}),
],
} satisfies Meta<typeof FormError>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
message: 'A manual error message',
},
};

export const CustomStyling: Story = {
args: {
message: 'A manual error message with custom styling',
className: 'bg-red-50 p-4 border border-red-200 rounded-md',
},
};
29 changes: 28 additions & 1 deletion apps/docs/src/remix-hook-form/form-error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ const TestFormWithError = ({
formErrorName = '_form',
customComponents = {},
className = '',
message = '',
}: {
initialErrors?: Record<string, { message: string }>;
formErrorName?: string;
customComponents?: { FormMessage?: React.ComponentType<FormMessageProps> };
className?: string;
message?: string;
}) => {
const mockFetcher = {
data: { errors: initialErrors },
Expand All @@ -55,7 +57,7 @@ const TestFormWithError = ({
return (
<RemixFormProvider {...methods}>
<form onSubmit={methods.handleSubmit}>
<FormError name={formErrorName} className={className} components={customComponents} />
<FormError name={formErrorName} className={className} components={customComponents} message={message} />
<TextField name="email" label="Email" />
<TextField name="password" label="Password" />
<Button type="submit">Submit</Button>
Expand Down Expand Up @@ -313,6 +315,31 @@ describe('FormError Component', () => {
});
});

describe('Uncontrolled Mode', () => {
it('displays manual message when provided', () => {
render(<TestFormWithError message="Manual error message" />);

expect(screen.getByText('Manual error message')).toBeInTheDocument();
});

it('manual message takes precedence over form state error', () => {
const errors = {
_form: { message: 'Form state error' },
};

render(<TestFormWithError initialErrors={errors} message="Manual error message" />);

expect(screen.getByText('Manual error message')).toBeInTheDocument();
expect(screen.queryByText('Form state error')).not.toBeInTheDocument();
});

it('renders even when form context is missing if message is provided', () => {
// Our implementation should be resilient.
render(<FormError message="Context-less error" />);
expect(screen.getByText('Context-less error')).toBeInTheDocument();
});
});

describe('Performance', () => {
it('does not re-render unnecessarily when unrelated form state changes', () => {
const renderSpy = jest.fn();
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/remix-hook-form/form-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { FormErrorField } from '../ui/form-error-field';

export type FormErrorProps = {
name?: string;
message?: string;
className?: string;
components?: Partial<FieldComponents>;
};

export function FormError({ name = '_form', ...props }: FormErrorProps) {
const { control } = useRemixFormContext();
export function FormError({ name, message, ...props }: FormErrorProps) {
const context = useRemixFormContext();
const control = context?.control;

return <FormErrorField control={control} name={name} {...props} />;
const effectiveName = name ?? (message ? undefined : '_form');

return <FormErrorField control={control} name={effectiveName} message={message} {...props} />;
}

FormError.displayName = 'FormError';
16 changes: 15 additions & 1 deletion packages/components/src/ui/form-error-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export interface FormErrorFieldProps<
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> {
control?: Control<TFieldValues>;
name: TName;
name?: TName;
message?: string;
className?: string;
components?: Partial<FieldComponents>;
}
Expand All @@ -18,9 +19,22 @@ export const FormErrorField = <
>({
control,
name,
message,
className,
components,
}: FormErrorFieldProps<TFieldValues, TName>) => {
if (message) {
return (
<FormItem className={className}>
<FormMessage Component={components?.FormMessage}>{message}</FormMessage>
</FormItem>
);
}

if (!name) {
return null;
}

return (
<FormField
control={control}
Expand Down