개발/Vue

[Vue] Provide, inject를 활용하여 Prop Drilling 해결하기

DOTBAAAM 2024. 11. 19. 15:32
반응형

Prop Drilling

일반적으로 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달해야 할 때 props를 사용합니다. 그러나 큰 컴포넌트 트리가 있고 깊이 중첩된 컴포넌트에 먼 조상 컴포넌트의 무언가가 필요한 경우 props 만 있으면 전체 부모 체인에 동일한 prop을 전달해야 합니다.

graph TD;
    Root --> Header;
    Root --> Main;
    Root --> Footer;
    Footer --> DeepChild
    Root -- Props ----> Footer;
    Footer -- Props ----> DeepChild;

<Footer> 컴포넌트는 이 prop이 전혀 필요하지 않을 수 있지만, <DeepChild>가 접근할 수 있도록 prop을 선언하고 전달해야 합니다. 더 긴 상위 체인이 있으면 그 과정에서 더 많은 컴포넌트가 영향을 받게 됩니다. 이것을 Prop Drilling이라고 합니다.

이러한 Prop Drillingprovideinject로 해결할 수 있습니다. 하위 트리의 모든 컴포넌트는 깊이에 상관없이 상위 체인의 컴포넌트에서 제공(provide)하는 의존성을 주입(inject) 할 수 있습니다.

graph TD;
    Root --> Header;
    Root --> Main;
    Root --> Footer;
    Footer --> DeepChild
    Root -- provide  ----> DeepChild;
    DeepChild -- inject  ----> Root;

Provide

컴포넌트의 하위 항목에 데이터를 제공합니다.

<script setup lang="ts">
import { provide } from 'vue';

provide(/* 키 */ 'message', /* 값 */ 'Hello, world!');
</script>

Inject

부모 컴포넌트에서 제공하는 데이터를 주입하기 위해 사용합니다.

<script setup lang="ts">
import { inject } from 'vue';

const message = inject('message');
</script>

provide와 inject를 이용하여 간단한 Form 컴포넌트를 작성하고, zod를 이용하여 스키마 선언 및 유효성을 검사해 보겠습니다.

Form 컴포넌트 작성

심볼 키 사용하기

잠재적 충돌을 피하기 위해 제공 키로 Symbol을 사용합니다.

// keys.ts
export const formErrorsInjectionKey = Symbol();

컴포넌트 구조

graph TD;
    VForm --> VFieldSet;
    VFieldSet --> VField;
    VForm -- provide ----> VField;
    VField -- inject ----> VForm;

컴포넌트 작성

<!-- /components/VForm.vue -->
<script setup lang="ts">
import { z } from 'zod';
import { formErrorsInjectionKey } from '~/keys';

interface Props {
  schema: any;
  values: z.infer<any>;
}

const props = defineProps<Props>();

const emit = defineEmits(['submit', 'error']);

const errors = ref<{
  [key in keyof z.infer<typeof props.schema>]?: string | undefined;
}>({});

const handleSubmit = async () => {
  const result = await props.schema.safeParseAsync(props.values);

  if (result.success) {
    errors.value = {};

    emit('submit', result.data);
  } else {
    errors.value = Object.fromEntries(
      Object.entries(result.error.flatten().fieldErrors).map(([k, v]: any) => [
        k,
        v?.[0]
      ])
    );

    emit('error', errors.value);
  }
};

provide(formErrorsInjectionKey, readonly(errors));
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <slot />
  </form>
</template>

<style scoped lang="scss"></style>

<!-- /components/VFieldSet.vue -->
<script setup lang="ts">
interface Props {
  disabled?: boolean;
}

withDefaults(defineProps<Props>(), {
  disabled: false
});
</script>

<template>
  <fieldset :disabled>
    <slot />
  </fieldset>
</template>

<style scoped lang="scss"></style>

<!-- /components/VField.vue -->
<script setup lang="ts">
import { formErrorsInjectionKey } from '~/keys';

interface Props {
  label?: string;
  name: string;
  tag?: string;
}

const props = withDefaults(defineProps<Props>(), {
  tag: 'div'
});

const errors = inject<{ readonly [k in Props['name']]?: string | undefined }>(
  formErrorsInjectionKey,
  {} // default value
);
</script>

<template>
  <component :is="tag">
    <label v-if="label">{{ label }}</label>
    <div>
      <slot />
      <slot name="error" :props="errors[props.name]">
        <div v-if="errors[props.name]">{{ errors[props.name] }}</div>
      </slot>
      <slot name="hint">
        <div v-if="hint && !errors[props.name]">{{ hint }}</div>
      </slot>
    </div>
  </component>
</template>

<style scoped lang="scss"></style>

스키마 선언 및 유효성 검사

<script setup lang="ts">
import { z } from 'zod';

interface Schema {
  id?: string;
  password?: string;
}

const schema = z
  .custom<Schema>()
  .refine(({ id }) => id, {
    message: '아이디를 입력해주세요.',
    path: ['id']
  })
  .refine(({ password }) => password, {
    message: '비밀번호를 입력해주세요.',
    path: ['password']
  });

const values = reactive<z.infer<typeof schema>>({
  id: '',
  password: ''
});

const { id, password } = toRefs(values);

const handleSubmit = (values: z.infer<typeof schema>) => {
  try {
    // ...
  } catch (error) {
    // ...
  }
};
</script>

<template>
  <VForm :schema :values @submit="handleSubmit">
    <VFieldset>
      <div>
        <VField label="ID" name="id">
          <input v-model="id" />
        </VField>
        <VField label="Password" name="password">
          <input v-model="password" type="password" />
        </VField>
      </div>
      <div>
        <button type="submit">Submit</button>
      </div>
    </VFieldset>
  </VForm>
</template>

<style scoped lang="scss"></style>

Provide(제공) / Inject(주입)
Zod

반응형