👻

Reactで、Featuresにスタイルを持たないようにしてみた

2025/01/08に公開

前提

  • 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",
      },
    },
  };

最後に

ぜひ、ご協力いただける方がいらっしゃいましたら、コメントをいただけると幸いです。
きっちりしたものが作れたら、「良い感じ」が良い感じにできそうなモジュールが公開できそうな予感がしてます。

GitHubで編集を提案

Discussion