🚧

シンプルなvee-validateを作ってみる

に公開

Social Databank Advent Calendar 2025 の8日目です。

vee-validateは使いやすいし素敵ですよね!
親コンポーネントでuseFormを宣言したら子コンポーネントでuseFieldが使えるようになるのがとても便利ですよね。
構造を知りたいので作っていきたいと思います☺️

1. useFieldを作る

まずは下記のような、1つの入力欄に対して値の管理、バリデーションの実行、エラー表示ができるuseFieldを作っていきます。

<script setup>
import { useField } from './form';

const email = useField('email', (value) => {
  if (!value) return 'メールアドレスは必須です';
  if (!value.includes('@')) return '有効なメールアドレスを入力してください';
  return true;
});
</script>
<template>
  <div>
    <input v-model="email.value.value" />
    <span v-if="email.errorMessage">{{ email.errorMessage }}</span>
    <button @click="email.validate()">バリデーション実行</button>
  </div>
</template>

1-1. 値の管理

まずは、入力値を管理するだけのシンプルな実装から始めます。

import { ref } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
) {
  const value = ref("");

  return { value };
};

1-2. 初期値の設定

次に初期値を設定できるようにします。

import { ref } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
+ options?: {initialValue: TValue}
) {
+ const value = ref(options?.initialValue || "");

  return { value };
};

1-3. 手動でバリデーションを実行

次に、引数にバリデーション関数rulesを渡した時に、手動でバリデーションを実行する関数を作ります。

import { ref } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: {initialValue: TValue}
) {
  const value = ref(options?.initialValue || "");
+ const errorMessage = ref<string | undefined>();

+ const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {
+   if (!rules) {
+     return { valid: true, errorMessage: undefined };
+   }
+   const result = rules(newValue);
+   if (result === true) {
+     return { valid: true, errorMessage: undefined };
+   }
+   return { valid: false, errorMessage: result };
+ }

+ function validate() {
+   const result = validateValue(value.value, rules);
+   errorMessage.value = result.errorMessage;
+   return result.valid;
+ }

  return {
    value,
+   validate,
+   errorMessage
  };
}

1-4. valueが変更したら自動でvalidationが実行されるようにする

computedを使用して、validateを自動で実行されるように修正します。

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue }
) {
  const value = ref(options?.initialValue || "");
  const errorMessage = ref<string | undefined>();

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {}
  const validate = () => {};

+ function setValue(newValue: TValue) {
+   value.value = newValue;
+   validate();
+ }

+ const valueProxy = computed({
+   get() {
+     return value.value;
+   },
+   set(newValue: TValue) {
+     setValue(newValue);
+   },
+ });

  return {
+   value: valueProxy,
    validate,
    errorMessage
  };
};
ファイル全体のコードはこちら
import { ref, computed } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue }
) {
  const value = ref(options?.initialValue || "");
  const errorMessage = ref<string | undefined>();

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {
    if (!rules) {
      return { valid: true, errorMessage: undefined };
    }
    const result = rules(newValue);
    if (result === true) {
      return { valid: true, errorMessage: undefined };
    }
    return { valid: false, errorMessage: result };
  }

  const validate = () => {
    const result = validateValue(value.value, rules);
    errorMessage.value = result.errorMessage;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
    validate();
  }

  const valueProxy = computed({
    get() {
      return value.value;
    },
    set(newValue: TValue) {
      setValue(newValue);
    },
  });

  return {
    value: valueProxy,
    validate,
    errorMessage
  };
};

1-5. メタ情報を保存する

ついでにメタ情報(validとtouched)の状態を保ちたいと思います。

+ import { ref, computed, reactive } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue }
) {
  const value = ref(options?.initialValue || "");
  const errorMessage = ref<string | undefined>();
+ const meta = reactive({
+   valid: true,
+   touched: false,
+ });

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {}

  const validate = () => {
    const result = validateValue(value.value, rules);
    errorMessage.value = result.errorMessage;
+   meta.valid = result.valid;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
+   meta.touched = true;
    validate();
  }

  const valueProxy = computed({});

  return {
    value: valueProxy,
    validate,
    errorMessage,
+   meta
  };
};
ファイル全体のコードはこちら
import { ref, computed, reactive } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue }
) {
  const value = ref(options?.initialValue || "");
  const errorMessage = ref<string | undefined>();
  const meta = reactive({
    valid: true,
    touched: false,
  });

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {
    if (!rules) {
      return { valid: true, errorMessage: undefined };
    }
    const result = rules(newValue);
    if (result === true) {
      return { valid: true, errorMessage: undefined };
    }
    return { valid: false, errorMessage: result };
  }

  const validate = () => {
    const result = validateValue(value.value, rules);
    errorMessage.value = result.errorMessage;
    meta.valid = result.valid;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
    meta.touched = true;
    validate();
  }

  const valueProxy = computed({});

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta
  };
};

1-6. おまけ: useFieldをdefineModelに対応させる

再利用できる入力コンポーネントを作るとき、親コンポーネントからv-modelで値を受け取るケースはよくあります。
そのため、下記のようにコンポーネント側にdefineModelを使っている場合でも扱いやすいように、柔軟に対応できる形で作っていこうと思います!

<script setup>
import { useField } from './form';

const modelValue = defineModel();
const email = useField('email', (value) => {
  if (!value) return 'メールアドレスは必須です';
  if (!value.includes('@')) return '有効なメールアドレスを入力してください';
  return true;
}, { syncVModel: true });
</script>
<template>
  <div>
    <input v-model="email.value.value" />
    <span v-if="email.errorMessage">{{ email.errorMessage }}</span>
    <button @click="email.validate()">検証</button>
  </div>
</template>

1-6-1. v-modelを使用するオプションがtrue且つ初期値がuseFieldに設定されていない場合に、defineModelから値をとって初期値を設定する

v-modelを使用するオプションをsyncVModelとして実装していきます!

+ import { computed, reactive, ref, getCurrentInstance } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
+ options?: { initialValue: TValue; syncVModel?: boolean }
) {
+ const vm = getCurrentInstance();
+ const useVmModel = options?.syncVModel && !!vm;
+ const initialValue =
+   useVmModel && !options?.initialValue
+     ? (vm.props["modelValue"] as TValue)
+     : options?.initialValue;
+ const value = ref(initialValue);
  const errorMessage = ref<string | undefined>();
  const meta = reactive({
    valid: true,
    touched: false,
  });

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {}
  const validate = () => {};
  function setValue(newValue: TValue) {}
  const valueProxy = computed({});

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta,
  };
};
ファイル全体のコードはこちら
import { computed, reactive, ref, getCurrentInstance } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue; syncVModel?: boolean }
) {
  const vm = getCurrentInstance();
  const useVmModel = options?.syncVModel && !!vm;
  const initialValue =
    useVmModel && !options?.initialValue
      ? (vm.props["modelValue"] as TValue)
      : options?.initialValue;
  const value = ref(initialValue);
  const errorMessage = ref<string | undefined>();
  const meta = reactive({
    valid: true,
    touched: false,
  });

  const validateValue = (
    newValue: TValue,
    rules?: (value: TValue) => string | true
  ): { valid: boolean; errorMessage?: string } => {
    if (!rules) {
      return { valid: true, errorMessage: undefined };
    }
    const result = rules(newValue);
    if (result === true) {
      return { valid: true, errorMessage: undefined };
    }
    return { valid: false, errorMessage: result };
  };

  const validate = () => {
    const result = validateValue(value.value, rules);
    errorMessage.value = result.errorMessage;
    meta.valid = result.valid;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
    meta.touched = true;
    validate();
  }

  const valueProxy = computed({
    get() {
      return value.value;
    },
    set(newValue: TValue) {
      setValue(newValue);
    },
  });

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta,
  };
};

1-6-2. 内部でvalueが変わった時にmodelValueが変化したことをemitする

v-modelに対応するということは双方向バインディングをしないといけないので、まず内から外の方向のものを実装していきます!
本筋と関係ないのでisEqualは vee-validateから拝借 してutil.tsに置いたものとします。

+ import { computed, reactive, ref, getCurrentInstance, watch } from "vue";
+ import { isEqual } from "./util";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue; syncVModel?: boolean }
) {
  const vm = getCurrentInstance();
  const useVmModel = options?.syncVModel && !!vm;
  const initialValue = useVmModel && !options?.initialValue? (vm.props["modelValue"] as TValue): options?.initialValue;
  const value = ref(initialValue);
  const errorMessage = ref<string | undefined>();
  const meta = reactive({ valid: true, touched: false });

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {}
  const validate = () => {};
  function setValue(newValue: TValue) {}
  const valueProxy = computed({});

+ if (useVmModel) {
+   watch(value, (newValue) => {
+     if (isEqual(newValue, vm.props["modelValue"])) {
+       return;
+     }
+
+     vm.emit("update:modelValue", newValue);
+   });
+ }

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta,
  };
};
ファイル全体のコードはこちら
import { computed, reactive, ref, getCurrentInstance, watch } from "vue";
import { isEqual } from "./util";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue; syncVModel?: boolean }
) {
  const vm = getCurrentInstance();
  const useVmModel = options?.syncVModel && !!vm;
  const initialValue =
    useVmModel && !options?.initialValue
      ? (vm.props["modelValue"] as TValue)
      : options?.initialValue;
  const value = ref(initialValue);
  const errorMessage = ref<string | undefined>();
  const meta = reactive({
    valid: true,
    touched: false,
  });

  const validateValue = (
    newValue: TValue,
    rules?: (value: TValue) => string | true
  ): { valid: boolean; errorMessage?: string } => {
    if (!rules) {
      return { valid: true, errorMessage: undefined };
    }
    const result = rules(newValue);
    if (result === true) {
      return { valid: true, errorMessage: undefined };
    }
    return { valid: false, errorMessage: result };
  };

  const validate = () => {
    const result = validateValue(value.value, rules);
    errorMessage.value = result.errorMessage;
    meta.valid = result.valid;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
    meta.touched = true;
    validate();
  }

  const valueProxy = computed({
    get() {
      return value.value;
    },
    set(newValue: TValue) {
      setValue(newValue);
    },
  });

  if (useVmModel) {
    watch(value, (newValue) => {
      if (isEqual(newValue, vm.props["modelValue"])) {
        return;
      }

      vm.emit("update:modelValue", newValue);
    });
  }

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta,
  };
};

1-6-3. 親コンポーネントがv-modelの状態を変えた時に値を修正する

最後に外から内の方向のものを実装していきます!

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue; syncVModel?: boolean }
) {
  const vm = getCurrentInstance();
  const useVmModel = options?.syncVModel && !!vm;
  const initialValue = useVmModel && !options?.initialValue? (vm.props["modelValue"] as TValue): options?.initialValue;
  const value = ref(initialValue);
  const errorMessage = ref<string | undefined>();
  const meta = reactive({ valid: true, touched: false });

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {}
  const validate = () => {};
  function setValue(newValue: TValue) {}
  const valueProxy = computed({});

  if (useVmModel) {
    watch(value, (newValue) => {
      if (isEqual(newValue, vm.props["modelValue"])) {
        return;
      }

      vm.emit("update:modelValue", newValue);
    });

+   watch(
+     () => vm.props["modelValue"],
+     (propValue) => {
+       if (isEqual(propValue, value.value)) {
+         return;
+       }
+
+       setValue(propValue);
+     }
+   );
  }

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta,
  };
};
ファイル全体のコードはこちら
import { computed, reactive, ref, getCurrentInstance, watch } from "vue";
import { isEqual } from "./util";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
  options?: { initialValue: TValue; syncVModel?: boolean }
) {
  const vm = getCurrentInstance();
  const useVmModel = options?.syncVModel && !!vm;
  const initialValue =
    useVmModel && !options?.initialValue
      ? (vm.props["modelValue"] as TValue)
      : options?.initialValue;
  const value = ref(initialValue);
  const errorMessage = ref<string | undefined>();
  const meta = reactive({
    valid: true,
    touched: false,
  });

  const validateValue = (
    newValue: TValue,
    rules?: (value: TValue) => string | true
  ): { valid: boolean; errorMessage?: string } => {
    if (!rules) {
      return { valid: true, errorMessage: undefined };
    }
    const result = rules(newValue);
    if (result === true) {
      return { valid: true, errorMessage: undefined };
    }
    return { valid: false, errorMessage: result };
  };

  const validate = () => {
    const result = validateValue(value.value, rules);
    errorMessage.value = result.errorMessage;
    meta.valid = result.valid;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
    meta.touched = true;
    validate();
  }

  const valueProxy = computed({
    get() {
      return value.value;
    },
    set(newValue: TValue) {
      setValue(newValue);
    },
  });

  if (useVmModel) {
    watch(value, (newValue) => {
      if (isEqual(newValue, vm.props["modelValue"])) {
        return;
      }

      vm.emit("update:modelValue", newValue);
    });

    watch(
      () => vm.props["modelValue"],
      (propValue) => {
        if (isEqual(propValue, value.value)) {
          return;
        }

        setValue(propValue);
      }
    );
  }

  return {
    value: valueProxy,
    validate,
    errorMessage,
    meta,
  };
};

2. useFormを作る

それでは下記のような複数の入力欄に対して、値の管理、バリデーションの実行、送信処理ができるuseFormを作っていきます。

<script setup>
import { useForm } from "./form";

const { handleSubmit, defineField, errors, isSubmitting } = useForm({
  validationSchema: {
    email: (value) => {
      if (!value) return 'メールアドレスは必須です';
      if (!value.includes('@')) return '有効なメールアドレスを入力してください';
      return true;
    },
    password: (value) => {
      if (!value) return 'パスワードは必須です';
      if (value.length < 8) return 'パスワードは8文字以上である必要があります';
      return true;
    },
  },
});

const [email] = defineField('email');
const [password] = defineField('password');

const onSubmit = handleSubmit((values) => {
  alert(JSON.stringify(values, null, 2));
});
</script>
<template>
  <form @submit="onSubmit">
    <input v-model="email" />
    <span v-if="errors.email">{{ errors.email }}</span>

    <input v-model="password" type="password" />
    <span v-if="errors.password">{{ errors.password }}</span>

    <button type="submit" :disabled="isSubmitting">送信</button>
  </form>
</template>

2-1. 最低限の必須の状態(valuesとerrorsとschema)を管理

import { reactive, readonly, ref } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;

  return {
    values: readonly(values),
    errors: readonly(errors),
  };
};

2-2. 1つの値を変更する

値を変更できるようにし、値が変更された後にvalidationを実行する関数を作ります。

import { reactive, readonly, ref } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;

+ function setFieldValue(name: string, value: any) {
+   values[name] = value;
+
+   if (schema?.[name]) {
+     const result = schema[name](value);
+     if (result === true) {
+       delete errors[name];
+     } else {
+       errors[name] = result;
+     }
+   }
+ }

  return {
    values: readonly(values),
    errors: readonly(errors),
+   setFieldValue,
  };
};

2-3. 各フィールドがv-modelにバインドできるようにする

各フィールドをv-modelへバインドできるようにする関数defineFieldを作成します。

import { reactive, readonly, ref, computed } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;

  function setFieldValue(name: string, value: any) {}

+ function defineField(name: string) {
+   const model = computed({
+     get() {
+       return values[name];
+     },
+     set(value) {
+       setFieldValue(name, value);
+     },
+   });
+
+   return [model];
+ }

  return {
    values: readonly(values),
    errors: readonly(errors),
+   defineField,
  };
};
ファイル全体のコードはこちら
import { reactive, readonly, ref, computed } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;

  function setFieldValue(name: string, value: any) {
    values[name] = value;

    if (schema?.[name]) {
      const result = schema[name](value);
      if (result === true) {
        delete errors[name];
      } else {
        errors[name] = result;
      }
    }
  }

  function defineField(name: string) {
    const model = computed({
      get() {
        return values[name];
      },
      set(value) {
        setFieldValue(name, value);
      },
    });
 
    return [model];
  }

  return {
    values: readonly(values),
    errors: readonly(errors),
    defineField,
  };
};

2-4. フォーム全体のバリデーションする

すべてのフィールドをまとめてバリデーションする機能を追加します。

import { reactive, readonly, ref, computed } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;

  function setFieldValue(name: string, value: any) {}

+ function validate() {
+   if (!schema) {
+     return { valid: true, errors: {} };
+   }
+
+   Object.keys(errors).forEach(key => delete errors[key]);
+
+   Object.keys(schema).forEach(name => {
+     const result = schema[name](values[name]);
+     if (result !== true) {
+       errors[name] = result;
+     }
+   });
+
+   return {
+     valid: Object.keys(errors).length === 0,
+     errors,
+   };
+ }

  function defineField(name: string) {}

  return {
    values: readonly(values),
    errors: readonly(errors),
    defineField,
+   validate,
  };
};
ファイル全体のコードはこちら
import { reactive, readonly, ref, computed } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;

  function setFieldValue(name: string, value: any) {
    values[name] = value;

    if (schema?.[name]) {
      const result = schema[name](value);
      if (result === true) {
        delete errors[name];
      } else {
        errors[name] = result;
      }
    }
  }

  function validate() {
    if (!schema) {
      return { valid: true, errors: {} };
    }

    Object.keys(errors).forEach(key => delete errors[key]);

    Object.keys(schema).forEach(name => {
      const result = schema[name](values[name]);
      if (result !== true) {
        errors[name] = result;
      }
    });

    return {
      valid: Object.keys(errors).length === 0,
      errors,
    };
  }

  function defineField(name: string) {
    const model = computed({
      get() {
        return values[name];
      },
      set(value) {
        setFieldValue(name, value);
      },
    });

    return [model];
  }

  return {
    values: readonly(values),
    errors: readonly(errors),
    defineField,
    validate,
  };
};

2-5. handleSubmit関数を追加

formで使用できるようにsubmit関数を追加します。

import { reactive, readonly, ref, computed } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;
+ const isSubmitting = ref(false);

  function setFieldValue(name: string, value: any) {}

  function validate() {}

+ function handleSubmit(onSubmit: (values: any) => void | Promise<void>) {
+   return async (event?: Event) => {
+     if (event) {
+       event.preventDefault();
+     }
+     if (isSubmitting.value){
+       return;
+     }
+
+     isSubmitting.value = true;
+
+     const result = validate();
+
+     if (result.valid) {
+       await onSubmit(values);
+     }
+
+     isSubmitting.value = false;
+   };
+ }

  function defineField(name: string) {}

  return {
    values: readonly(values),
    errors: readonly(errors),
    isSubmitting,
    defineField,
+   handleSubmit,
  };
};
ファイル全体のコードはこちら
import { reactive, readonly, ref, computed } from "vue";

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;
  const isSubmitting = ref(false);

  function setFieldValue(name: string, value: any) {
    values[name] = value;

    if (schema?.[name]) {
      const result = schema[name](value);
      if (result === true) {
        delete errors[name];
      } else {
        errors[name] = result;
      }
    }
  }

  function validate() {
    if (!schema) {
      return { valid: true, errors: {} };
    }

    Object.keys(errors).forEach(key => delete errors[key]);

    Object.keys(schema).forEach(name => {
      const result = schema[name](values[name]);
      if (result !== true) {
        errors[name] = result;
      }
    });

    return {
      valid: Object.keys(errors).length === 0,
      errors,
    };
  }

  function handleSubmit(onSubmit: (values: any) => void | Promise<void>) {
    return async (event?: Event) => {
      if (event) {
        event.preventDefault();
      }
      if (isSubmitting.value){
        return;
      }

      isSubmitting.value = true;

      const result = validate();

      if (result.valid) {
        await onSubmit(values);
      }

      isSubmitting.value = false;
    };
  }

  function defineField(name: string) {
    const model = computed({
      get() {
        return values[name];
      },
      set(value) {
        setFieldValue(name, value);
      },
    });

    return [model];
  }

  return {
    values: readonly(values),
    errors: readonly(errors),
    isSubmitting,
    defineField,
    handleSubmit,
  };
};

これで、最低限のuseFormの基本的な機能が完成しました!やった!

3. useFormとuseFieldを併用する

下記のように、useFormを親コンポーネントで定義して、useFieldを子コンポーネントで使用できるように実装していきたいと思います!

Parent.vue
<script setup>
import EmailInput from "./EmailInput.vue";
import PasswordInput from "./PasswordInput.vue";
import { useForm } from "./composables/form.ts"

const { handleSubmit, defineField, errors, isSubmitting } = useForm({
  validationSchema: {
    email: (value) => {
      if (!value) return 'メールアドレスは必須です';
      if (!value.includes('@')) return '有効なメールアドレスを入力してください';
      return true;
    },
    password: (value) => {
      if (!value) return 'パスワードは必須です';
      if (value.length < 8) return 'パスワードは8文字以上である必要があります';
      return true;
    },
  },
});

const onSubmit = handleSubmit((values) => {
  alert(JSON.stringify(values, null, 2));
});
</script>
<template>
  <form @submit="onSubmit">
    <EmailInput path="email" />
    <PasswordInput path="password" />
    <button type="submit" :disabled="isSubmitting">送信</button>
  </form>
</template>
EmailInput.vue
<script setup lang="ts">
import { useField } from "./form"

const { path } = defineProps<{path: string}>();
const email = useField(path);
</script>
<template>
  <div>
    <input v-model="email.value.value" />
    <span v-if="email.errorMessage">{{ email.errorMessage }}</span>
  </div>
</template>

3-1. 子コンポーネントでformの状態にアクセスできるようにする

Vue.jsのprovide/injectを使用して、formの状態をアクセスできるようにuseFormを変更していきたいと思います。
まず、子コンポーネントにも渡す情報をformContextとしてobjectを作り、Symbolで作成した一意のkeyとしてformContextをprovideに渡したいと思います!

Symbolを使う理由は、他のライブラリやコードと名前が衝突しないようにするためです。文字列のキーだと偶然同じ名前を使ってしまう可能性がありますが、Symbolは必ず一意な値になるため安全です。

+ import { reactive, readonly, ref, computed, provide, type InjectionKey } from "vue";

+ // フォームコンテキストの型定義
+ interface FormContext {
+   values: Record<string, any>;
+   errors: Record<string, string>;
+   isSubmitting: boolean;
+   setFieldValue: (name: string, value: any) => void;
+   defineField: (name: string) => [any];
+   handleSubmit: (onSubmit: (values: any) => void | Promise<void>) => (event?: Event) => Promise<void>;
+ }

+ export const FormContextKey: InjectionKey<FormContext> = Symbol('vee-validate-form');

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;
  const isSubmitting = ref(false);

  function setFieldValue(name: string, value: any) {}
  function validate() {}
  function handleSubmit(onSubmit: (values: any) => void | Promise<void>) {}
  function defineField(name: string) {}

+ const formContext: FormContext = {
+   values: readonly(values),
+   errors: readonly(errors),
+   isSubmitting,
+   setFieldValue,
+   defineField,
+   handleSubmit,
+ }

+ provide(FormContextKey, formContext);

  return {
    values: readonly(values),
    errors: readonly(errors),
    isSubmitting,
    defineField,
    handleSubmit,
  };
};
ファイル全体のコードはこちら
import { reactive, readonly, ref, computed, provide, type InjectionKey } from "vue";

// フォームコンテキストの型定義
interface FormContext {
  values: Record<string, any>;
  errors: Record<string, string>;
  isSubmitting: Ref<boolean>;
  setFieldValue: (name: string, value: any) => void;
  defineField: (name: string) => [any];
  handleSubmit: (onSubmit: (values: any) => void | Promise<void>) => (event?: Event) => Promise<void>;
}

export const FormContextKey: InjectionKey<FormContext> = Symbol('vee-validate-form');

export const useForm = (options?: {
  validationSchema?: Record<string, (value: any) => string | true>;
}) => {
  const values = reactive({});
  const errors = reactive<Record<string, string>>({});
  const schema = options?.validationSchema;
  const isSubmitting = ref(false);

  function setFieldValue(name: string, value: any) {
    values[name] = value;

    if (schema?.[name]) {
      const result = schema[name](value);
      if (result === true) {
        delete errors[name];
      } else {
        errors[name] = result;
      }
    }
  }

  function validate() {
    if (!schema) {
      return { valid: true, errors: {} };
    }

    Object.keys(errors).forEach(key => delete errors[key]);

    Object.keys(schema).forEach(name => {
      const result = schema[name](values[name]);
      if (result !== true) {
        errors[name] = result;
      }
    });

    return {
      valid: Object.keys(errors).length === 0,
      errors,
    };
  }

  function handleSubmit(onSubmit: (values: any) => void | Promise<void>) {
    return async (event?: Event) => {
      if (event) {
        event.preventDefault();
      }
      if (isSubmitting.value){
        return;
      }

      isSubmitting.value = true;

      const result = validate();

      if (result.valid) {
        await onSubmit(values);
      }

      isSubmitting.value = false;
    };
  }

  function defineField(name: string) {
    const model = computed({
      get() {
        return values[name];
      },
      set(value) {
        setFieldValue(name, value);
      },
    });

    return [model];
  }

  const formContext: FormContext = {
    values: readonly(values),
    errors: readonly(errors),
    isSubmitting,
    setFieldValue,
    defineField,
    handleSubmit,
  }

  provide(FormContextKey, formContext);

  return {
    values: readonly(values),
    errors: readonly(errors),
    isSubmitting,
    defineField,
    handleSubmit,
  };
};

3-2. useFieldでformのコンテキストを取得できるようにする

次に、useFieldがinjectを使って親のuseFormが提供したformContextを取得し、フォームの状態へアクセスできるようにします。

+ import { inject, ref, computed } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
) {
+ const formContext = inject(FormContextKey, null);

+ // フォームコンテキストがある場合(useFormの子コンポーネント)
+ if (formContext) {
+   const value = computed({
+     get() {
+       return formContext.values[path];
+     },
+     set(newValue: TValue) {
+       formContext.setFieldValue(path, newValue);
+     },
+   });
+
+   const errorMessage = computed(() => formContext.errors[path]);
+
+   return {
+     value,
+     errorMessage,
+   };
+ }

+ // スタンドアロンモード(フォームコンテキストがない場合)
  const value = ref("");
  const errorMessage = ref<string | undefined>();

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {}
  const validate = () => {};
  function setValue(newValue: TValue) {}
  const valueProxy = computed({
    get() {
      return value.value;
    },
    set(newValue: TValue) {
      setValue(newValue);
    },
  });

  return {
    value: valueProxy,
    errorMessage,
    validate,
  };
};
ファイル全体のコードはこちら
import { inject, ref, computed } from "vue";

export function useField<TValue>(
  path: string,
  rules?: (value: TValue) => string | true,
) {
  const formContext = inject(FormContextKey, null);

  // フォームコンテキストがある場合(useFormの子コンポーネント)
  if (formContext) {
    const value = computed({
      get() {
        return formContext.values[path];
      },
      set(newValue: TValue) {
        formContext.setFieldValue(path, newValue);
      },
    });

    const errorMessage = computed(() => formContext.errors[path]);

    return {
      value,
      errorMessage,
    };
  }

  // スタンドアロンモード(フォームコンテキストがない場合)
  const value = ref("");
  const errorMessage = ref<string | undefined>();

  const validateValue = (newValue: TValue, rules?: (value: TValue) => string | true): {valid: boolean, errorMessage?: string} => {
    if (!rules) {
      return { valid: true, errorMessage: undefined };
    }
    const result = rules(newValue);
    if (result === true) {
      return { valid: true, errorMessage: undefined };
    }
    return { valid: false, errorMessage: result };
  }

  const validate = () => {
    const result = validateValue(value.value as TValue, rules);
    errorMessage.value = result.errorMessage;
    return result.valid;
  };

  function setValue(newValue: TValue) {
    value.value = newValue;
    validate();
  }

  const valueProxy = computed({
    get() {
      return value.value;
    },
    set(newValue: TValue) {
      setValue(newValue);
    },
  });

  return {
    value: valueProxy,
    errorMessage,
    validate,
  };
};

これで、親コンポーネントでuseFormを使い、子コンポーネントでuseFieldを使うと、自動的にフォームの状態が共有されるようになりました!

感想

とっても勉強になりました!
型周りを簡略化のためにしっかり実装していないのがちょっと心残りですが、
この関数のMVPってなんだろうなと考えて、ライブラリのコードを見つつ実装していくのはとても楽しかったです!

参考文献

ソーシャルデータバンク テックブログ

Discussion