T
TanStack5mo ago
eastern-cyan

using i18n (react-i18next) with tanstack form

How do I use i18n with ts form in a typesafe way? Currently I'm doing it like this:
//validation.tsx
export const zCreateTaskInputSchema = (t: TFunction<'tasks'>) =>
z.object({
title: z.string().min(1, t("title"))
});

//form.tsx
const { t } = useTranslation("tasks");

const form = useAppForm({
validators: {
onChange: zCreateTaskInputSchema(t),
},
defaultValues: {
title: "",
}
});

console.log(t('title')) //<-- text changes when locale changes
console.log(form.getAllErrors()); //<--- error text stays the same when locale changes, I need to retrigger validation for it to take effect
//validation.tsx
export const zCreateTaskInputSchema = (t: TFunction<'tasks'>) =>
z.object({
title: z.string().min(1, t("title"))
});

//form.tsx
const { t } = useTranslation("tasks");

const form = useAppForm({
validators: {
onChange: zCreateTaskInputSchema(t),
},
defaultValues: {
title: "",
}
});

console.log(t('title')) //<-- text changes when locale changes
console.log(form.getAllErrors()); //<--- error text stays the same when locale changes, I need to retrigger validation for it to take effect
But the problem is that the form's errors dont change when the locale changes (I guess this is because form uses signals under the hood and doesn't pick up the changes from react). Is there a recommended way to do this in a typesafe way so the locale changes get propagated to the form's state?
6 Replies
adverse-sapphire
adverse-sapphire5mo ago
When a validator runs it sets the error in the form internal state, so if the validator returns a string, that's what is stored in the form state. It's just a string, not a function or something that can update when the language is changed. What I would do instead is to just return the localization key in the error (the string title in your snippet) so that form handles validation and the localization library handles localization Speaking of typesafety I don't have any quick idea right now as StandardSchemaV1Issue["message"] is just a string
eastern-cyan
eastern-cyanOP5mo ago
yeah thats the issue, currently i can use TFunction to have type safety for error messages, but im not aware how i could to this with just the keys in the locale files without too much boilerplate and type casting
adverse-sapphire
adverse-sapphire5mo ago
It's more a zod & i18n thing
eastern-cyan
eastern-cyanOP5mo ago
yeah, i havent found a nice way of having it be typesafe so i was thinking of trying to "hack" around with form. Do you think triggering validation on language change is a good idea? For eg:
useEffect(() => {
const reTriggerOnLocaleChange = async () => {
await form.validate("change");
};

void reTriggerOnLocaleChange();
}, [i18n.language]);
useEffect(() => {
const reTriggerOnLocaleChange = async () => {
await form.validate("change");
};

void reTriggerOnLocaleChange();
}, [i18n.language]);
Could probably be extracted into a hook, but I need a corresponding wrapper like withForm where I can pass the form as an argument
adverse-sapphire
adverse-sapphire5mo ago
It might work, but you're basically re-running the entire validation logic to simply change a translated string
eastern-cyan
eastern-cyanOP5mo ago
dont know how would i update the error fields directly with this approach (passing t instead of locale keys) in the end, i tinkered around with typescript a bit and made typesafe wrapper functions to handle this, feel like its the best solution:
import type Resources from "~/types/i18next-resources";

type KeyPath<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends string
? `${Prefix}${K & string}`
: T[K] extends Record<string, unknown>
? KeyPath<T[K], `${Prefix}${K & string}.`>
: never;
}[keyof T];

type I18nKey<NS extends keyof Resources, K extends KeyPath<Resources[NS]>> = `${NS}:${K}`;

export const i18nKey = <NS extends keyof Resources, K extends KeyPath<Resources[NS]>>(
namespace: NS,
key: K,
): I18nKey<NS, K> => {
const keyStr = key as string;
return `${namespace}:${keyStr}` as I18nKey<NS, K>;
};

// example:
const key = i18nKey('validation', 'title');
//^key = validation:title
import type Resources from "~/types/i18next-resources";

type KeyPath<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends string
? `${Prefix}${K & string}`
: T[K] extends Record<string, unknown>
? KeyPath<T[K], `${Prefix}${K & string}.`>
: never;
}[keyof T];

type I18nKey<NS extends keyof Resources, K extends KeyPath<Resources[NS]>> = `${NS}:${K}`;

export const i18nKey = <NS extends keyof Resources, K extends KeyPath<Resources[NS]>>(
namespace: NS,
key: K,
): I18nKey<NS, K> => {
const keyStr = key as string;
return `${namespace}:${keyStr}` as I18nKey<NS, K>;
};

// example:
const key = i18nKey('validation', 'title');
//^key = validation:title
now the usage in app becomes:
//validation.tsx
export const zCreateTaskInputSchema = z.object({
title: z.string().min(1, i18nKey("validation", "title"))
});

//form.tsx
const { t } = useTranslation("validation");

const form = useAppForm({
validators: {
onChange: zCreateTaskInputSchema,
},
defaultValues: {
title: "",
},
});
//validation.tsx
export const zCreateTaskInputSchema = z.object({
title: z.string().min(1, i18nKey("validation", "title"))
});

//form.tsx
const { t } = useTranslation("validation");

const form = useAppForm({
validators: {
onChange: zCreateTaskInputSchema,
},
defaultValues: {
title: "",
},
});
and error display component:
import i18next from "i18next";

export const FieldMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const field = useFieldContext();

const isTouched = field.state.meta.isTouched;
const hasErrors = field.state.meta.errors.length > 0;

const formatErrorMessage = (error: unknown) => {
let errorString;

if (typeof error === "string") {
errorString = error;
} else if (error instanceof Error) {
errorString = error.message;
} else if (error && typeof error === "object" && "message" in error) {
errorString = String(error.message);
} else {
errorString = `Unhandled error format - ${JSON.stringify(error)}`;
}

// Translate if it's an i18n key (contains a colon) (maybe implement better check of this in the future)
return errorString.includes(":") ? i18next.t(errorString) : errorString;
};

const formattedErrorMessages = field.state.meta.errors.map(formatErrorMessage).join(", ");

const body = isTouched && hasErrors ? formattedErrorMessages : children;

if (!body) {
return null;
}

return (
<p
ref={ref}
id={`${field.name}-message`}
className={cn(
"from-muted-foreground text-sm font-medium",
isTouched && hasErrors && "text-destructive",
className,
)}
{...props}
>
{body}
</p>
);
});
import i18next from "i18next";

export const FieldMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const field = useFieldContext();

const isTouched = field.state.meta.isTouched;
const hasErrors = field.state.meta.errors.length > 0;

const formatErrorMessage = (error: unknown) => {
let errorString;

if (typeof error === "string") {
errorString = error;
} else if (error instanceof Error) {
errorString = error.message;
} else if (error && typeof error === "object" && "message" in error) {
errorString = String(error.message);
} else {
errorString = `Unhandled error format - ${JSON.stringify(error)}`;
}

// Translate if it's an i18n key (contains a colon) (maybe implement better check of this in the future)
return errorString.includes(":") ? i18next.t(errorString) : errorString;
};

const formattedErrorMessages = field.state.meta.errors.map(formatErrorMessage).join(", ");

const body = isTouched && hasErrors ? formattedErrorMessages : children;

if (!body) {
return null;
}

return (
<p
ref={ref}
id={`${field.name}-message`}
className={cn(
"from-muted-foreground text-sm font-medium",
isTouched && hasErrors && "text-destructive",
className,
)}
{...props}
>
{body}
</p>
);
});

Did you find this page helpful?