👻
Reactで、Featuresにスタイルを持たないようにしてみた
前提
- bulletproof-react を基準にしていること
- tanstack-router を使用していること
- react-hook-form と zod を使用していること
- shadcn(UI ライブラリ)を使用していること
- 筆者自身に技術力を期待しないこと
モチベーション
いつも悩んでいたのが、pages と features 両方でレイアウト(以下スタイル)を適応していた。features は、form タグの中身のレイアウトも適応させる形にしていた。
2024 年 9 月あたりで、SOLID 原則について改めて触れる機会があり、
ふと、「単一責任を考えるのであればスタイルの適応は一箇所がいいな」と思い、
pages の中にまとめる形を構築したいと動き出した。
さらには、この記事の内容をアップデートさせて、良い感じのパッケージを作りたいと思っている。
やったこと
タイトルの通り、features 配下でスタイルを適応させず、
pages 配下で、フォームの input や select の UI パーツを表示させるように構築した。
export const LoginPage: FC<{}> = () => {
const navigate = useNavigate();
const onSuccess = () => {
navigate({
to: "/app",
});
};
return (
<>
<LoginForm onSuccess={onSuccess}>
{(fields) => (
<>
{fields.username}
{fields.password}
<Button type="submit">Login</Button>
</>
)}
</LoginForm>
</>
);
};
type FormValues = {
username: string;
password: string;
};
export const LoginForm: FC<FormFeatureProp<FormValues>> = ({
onSuccess,
...props
}) => {
const mutation = useLogin({
onSuccess,
});
const onSubmit = (values: FormValues) => {
mutation.mutate(values);
};
const fieldModel: ArgsModelType<FormValues> = {
username: {
field: FieldType.Input,
fieldOptions: {
label: "username",
},
},
password: {
field: FieldType.Input,
fieldOptions: {
type: "password",
label: "password",
},
},
};
const validator: Record<keyof FormValues, any> = {
username: z.string({
message: MESSAGE.REQUIRED,
}),
password: z.string({
message: MESSAGE.REQUIRED,
}),
};
return (
<Form<FormValues>
onSubmit={onSubmit}
fieldModel={fieldModel}
validator={validator}
{...props}
/>
);
};
export type FormFeatureProp<TFormValues> = {
onSuccess: () => void;
keys?: FieldSchemaKey<TFormValues>;
children: (fields: RenderFieldsType<TFormValues>) => React.ReactNode;};
export const Form = ({
...
fieldModel,
validator,
keys,
debug = false,
}: FormProps<TFormValues>) => {
...
const { model, schema } = getModelSchema<TFormValues>({
fieldModel,
validator,
keys,
});
const methods = useForm<TFormValues>({
...options,
resolver: schema && zodResolver(schema),
});
const fields = getFields({ control: methods.control, model });
...
return (
<UIForm {...methods}>
<form>
{children(fields)}
</form>
</UIForm>
);
};
export type FieldSchemaKey<TFormValues> = (keyof TFormValues)[];
export type ModelSchemaType<TFormValues> = {
fieldModel: ArgsModelType<TFormValues>;
validator: Record<keyof TFormValues, ZodFirstPartySchemaTypes>;
keys?: FieldSchemaKey<TFormValues>;
};
export const getModelSchema = <TFormValues extends Record<string, unknown>>({
fieldModel,
validator,
keys,
}: ModelSchemaType<TFormValues>) => {
// defineModelは引数を返すだけ
const definedModel = defineModel(
filterObject<typeof fieldModel>(fieldModel, keys)
);
const schema = z.object(filterObject<typeof validator>(validator, keys));
return {
model: definedModel,
schema,
};
};
export type RenderFieldsType<T> = {
[K in keyof T]: React.ReactNode;
};
interface GetFieldsProps<T extends FieldValues> {
model: ArgsModelType<T>;
control: Control<T>;
}
export const getFields = <TFormValues extends FieldValues>({
model,
control,
}: GetFieldsProps<TFormValues>): RenderFieldsType<TFormValues> => {
const keys = Object.keys(model);
const fields = keys.reduce((prev, key) => {
const Field = getField(model[key].field);
if (!Field) {
return {
...prev,
[key]: null,
};
}
const fieldOptions = model[key].fieldOptions ?? {};
return {
...prev,
[key]: (
<FormField
control={control}
name={key as Path<TFormValues>}
render={({ field }) => <Field {...fieldOptions} {...field} />}
/>
),
};
}, {});
return fields as RenderFieldsType<TFormValues>;
};
export type FieldOptions<TField> = typeof TField extends FieldType.Input
? InputFieldProps
: typeof TField extends FieldType.TextArea
? TextAreaFieldProps
: typeof TField extends FieldType.Select
? SelectFieldProps
: never;
export type ModelType = {
field: FieldType;
fieldOptions?: FieldOptions<ModelType["field"]>;
};
export type ArgsModelType<T> = {
[K in keyof T]: ModelType;
};
フォームの要素を減らす場合
keys を追加するだけ。
keys の要素に関しては、型チェックできてる。
FromValues に定義した key しか入れられないようにはなっている。
keys を定義した場合、他のキーの要素は null になる。
この場合は、password が null
return (
<>
<LoginForm onSuccess={onSuccess} keys={["username"]}>
{(fields) => (
<>
{fields.username}
{fields.password}
<Button type="submit">Login</Button>
</>
)}
</LoginForm>
</>
);
一応、考えながら作ったのは良いものの、 レンダリング回数多いし、 型もイマイチな状態
今後、したいこと
1. fields にないキーに対してエラーを吐くこと
<LoginForm onSuccess={onSuccess} keys={["username"]}>
{(fields) => (
<>
{fields.username}
{fields.password}
<Button type="submit">Login</Button>
</>
)}
</LoginForm>
2. レンダリング数の調整
3. fieldOptions の型チェック
field の値によって、fieldsOptions の型をチェックしたい
const fieldModel: ArgsModelType<FormValues> = {
username: {
field: FieldType.Input,
// ↓これら
fieldOptions: {
label: "username",
// ↓こう言うものに対してエラーを吐いて欲しい
test: 'test'
},
},
password: {
field: FieldType.Input,
fieldOptions: {
type: "password",
label: "password",
},
},
};
最後に
ぜひ、ご協力いただける方がいらっしゃいましたら、コメントをいただけると幸いです。
きっちりしたものが作れたら、「良い感じ」が良い感じにできそうなモジュールが公開できそうな予感がしてます。
Discussion