Modeling Complex Interfaces in Vue Components: Part 1

June 11, 2025
Updated: June 19, 2025 at 2:26 am

Recently, I was having a conversation with another engineer about how we want to approach a particular problem. We were working on creating a Vue component and came across a non-trivial implementation detail in which they were having a hard time understanding how to encapsulate and decouple the child component details from the parent.

The component - multi-step module

We have a component library that we are using. This component library provides what is referred to as a "multi-step module"; think of it as a modal that is generally used when presenting a user with sequential forms that need to be filled out. This module contains a header for title; it contains a footer with a button for submission/proceeding; and the body, which can contain pretty much anything.

Basic multi-step module
Basic multi-step module

Now that we have the component, let's take a look at a super simple use case for this component. Let's say we want to use this component as part of a user onboarding journey; we want to capture the user's first name, last name and phone number.

<template>
  <MultiStepModule>
    <template #header>User Onboarding</template>

    <template #body>
      <!-- User details form -->
      <div class="user-details-form">
        <label for="first-name">First name</label>
        <input id="first-name" v-model="firstName"/>

        <label for="last-name">Last name</label>
        <input id="last-name" v-model="lastName"/>

        <label for="phone-number">Phone number</label>
        <input id="phone-number" v-model="phoneNumber"/>
      </div>
    </template>

    <template #submissionButton>
      <button @click="submit">Submit</button>
    </template>
  </MultiStepModule>
</template>

<script setup>
  import {ref} from 'vue';

  /* Multi-step module state */
  const firstName = ref('');
  const lastName = ref('');
  const phoneNumber = ref('');

  const submit = () => {
    // ...user details form submission logic
    // ...module dismissal
  };
</script>

This all seems pretty straightforward. However, in this example we are have a singular step. We are using the multi-step module but only have one form.

So how does this change if we were to introduce a second step? Let's work through an example.

Let's say we want to introduce a second step to capture an emergency contact for the user; in case of emergencies, we want to know the name of the person we can reach out to, their phone number and their email.

To accomplish this, we are going to need to update the code we have above to introduce a new form. The first step will be the user contact info form, the second step will be the emergency contact form.

<template>
  <MultiStepModule>
    <template #header>User Onboarding</template>

    <template #body>
      <!-- User details form -->
      <div v-if="step === 1" class="user-details-form">...</div>

      <!-- Emergency contact form -->
      <div v-else-if="step === 2" class="emergency-contact-form">
        <label for="emergency-contact-name">Emergency contact's full name</label>
        <input id="emergency-contact-name" v-model="emergencyContactName"/>

        <label for="emergency-contact-phone">Phone number</label>
        <input id="emergency-contact-phone" v-model="emergencyContactPhoneNumber"/>

        <label for="emergency-contact-email">Email</label>
        <input type="email" id="emergency-contact-email" v-model="emergencyContactEmail"/>
      </div>
    </template>

    <template #submissionButton>...</template>
  </MultiStepModule>
</template>

<script setup>
  import {ref} from 'vue';

  /* Multi-step module state */
  const step = ref(1);

  /* User details */
  // ...

  /* Emergency contact */
  const emergencyContactName = ref('');
  const emergencyContactPhoneNumber = ref('');
  const emergencyContactEmail = ref('');

  const submit = () => {
    if (step.value === 1) {
      // ...user details form submission logic
      step.value += 1; // Proceed to next step
    } else if (step.value === 2) {
      // ...emergency contact form submission logic
      // ...module dismissal
    }
  };
</script>

Awesome, this is fantastic! Only ... it doesn't scale at all. As we increase the number of steps, we increase the quantity of state this component is required to manage, and we increase the number of branches in our submission logic that needs to occur.

Ideally, this component should only be in charge of what is displayed and what occurs when the submission button is pressed. So how can we accomplish this? Let's go back to a diagram.

What we want to do is break the forms out into their own components, where state for the components can be managed in isolation. The module doesn't care about the details of the forms, the forms don't care where they are placed.

Multi-step module w/form components
Multi-step module w/form components

User form

<template>
  <label for="first-name">First name</label>
  <input id="first-name" v-model="firstName"/>

  <label for="last-name">Last name</label>
  <input id="last-name" v-model="lastName"/>

  <label for="phone-number">Phone number</label>
  <input id="phone-number" v-model="phoneNumber"/>
</template>

<script setup>
  import {ref} from 'vue';

  const firstName = ref('');
  const lastName = ref('');
  const phoneNumber = ref('');
</script>

Emergency contact form

<template>
  <label for="emergency-contact-name">Emergency contact's full name</label>
  <input id="emergency-contact-name" v-model="emergencyContactName"/>

  <label for="emergency-contact-phone">Phone number</label>
  <input id="emergency-contact-phone" v-model="emergencyContactPhoneNumber"/>

  <label for="emergency-contact-email">Email</label>
  <input type="email" id="emergency-contact-email" v-model="emergencyContactEmail"/>
</template>

<script setup>
  import {ref} from 'vue';

  const emergencyContactName = ref('');
  const emergencyContactPhoneNumber = ref('');
  const emergencyContactEmail = ref('');
</script>

Multi-step module component

<template>
  <MultiStepModule>
    <template #header>User Onboarding</template>

    <template #body>
      <UserForm v-if="step === 1"/>
      <EmergencyContactForm v-else-if="step === 2"/>
    </template>

    <template #submissionButton>...</template>
  </MultiStepModule>
</template>

<script setup>
  import {ref} from 'vue';

  import UserForm from './UserForm.vue';
  import EmergencyContactForm from './EmergencyContactForm.vue';

  /* Multi-step module state */
  const step = ref(1);

  const submit = () => {
    if (step.value === 1) {
      step.value += 1;
    } else if (step.value === 2) {
      // module dismissal
    }
  };
</script>

As the great Bruce Almighty would say, "B-E-A-utiful"! We've officially removed the forms and their state from the multi-step module. The module is now simply in charge of knowing how many steps there are, what step the user is on and navigation between those steps.

Buuuuttttt, there's an issue. If you haven't noticed, it's the module that is in charge of the submission. And now that the module doesn't have context to form state, we need to figure out a way to capture the fact that the submission occurred, grab the form state, submit that form state and then proceed to the next step. But how? There are several approaches, but we will take a look at two here.

defineExpose - Exposing component data

The ChatGPT approach would be to make use of Vue's built-in defineExpose() macro function. By default, Vue components that are built with Composition API will keep their properties private. What defineExpose() does is it allows the engineer to explicitly state which properties are publicly available to the parent of that parent where a ref is defined.

If we were to take the defineExpose() approach to solving this form submission problem, we would need to create a submission function in the form components and add the submission function to the defineExpose() macro. Once done, we would need to add a ref to each of those form instances in the multi-step module and modify the multi-step module's submit function to execute the form's publicly exposed submission function. Let's check it out:

User form

<template>
  <!-- User form fields -->
</template>

<script setup>
  import {ref} from 'vue';

  const firstName = ref('');
  const lastName = ref('');
  const phoneNumber = ref('');

  const submitUserForm = () => {
    // Submission form logic (e.g. store in localStorage).
  };

  defineExpose({submitUserForm});
</script>

Emergency contact form

<template>
  <!-- Emergency contact form fields -->
</template>

<script setup>
  import {ref} from 'vue';

  const emergencyContactName = ref('');
  const emergencyContactPhoneNumber = ref('');
  const emergencyContactEmail = ref('');

  const submitEmergencyContactForm = () => {
    // Submission form logic (e.g. store in localStorage).
  };

  defineExpose({submitEmergencyContactForm});
</script>

Multi-step module component

<template>
  <MultiStepModule>
    <template #header>User Onboarding</template>

    <template #body>
      <UserForm v-if="step === 1" ref="userForm"/>
      <EmergencyContactForm v-else-if="step === 2" ref="emergencyContactForm"/>
    </template>

    <template #submissionButton>...</template>
  </MultiStepModule>
</template>

<script setup>
  import {ref} from 'vue';

  import UserForm from './UserForm.vue';
  import EmergencyContactForm from './EmergencyContactForm.vue';

  const userForm = ref(null);
  const emergencyContactForm = ref(null);

  /* Multi-step module state */
  const step = ref(1);

  const submit = () => {
    if (step.value === 1) {
      userForm.value?.submitUserForm();
      step.value += 1;
    } else if (step.value === 2) {
      emergencyContactForm.value.submitEmergencyContactForm();
      // module dismissal
    }
  };
</script>

Sweet! Everything is working again. When the multi-step module appears, the first form is shown. When the first form is submitted, the multi-step module calls the first forms submission function and continues on to the next step; and so on.

Unfortunately, this is actually problematic. Here are a list of the problems with this approach as it relates to our example.

Tight coupling and encapsulation leak

The parent and child are tightly coupled. The multi-step module needs to have a deeper understanding of the inner workings of the form components. At the very highest level, it needs to be aware of the function name, but more holistically, it needs to be aware of everything that occurs within that function and the data that function exposes.

Say, for example, that our user form submission function can throw an error. If we are taking the approach that the multi-step module calls the user form's function, then the multi-step module then needs to do error handling for that function call; this results in an "encapsulation leak".

An encapsulation leak occurs when a child's methods, data, logic, etc. become part of the parent's concern.

Implicit contract

With the defineExpose() macro function approach, there is no indicator as to how that form is supposed to be handled. The form itself doesn't contain a way of submitting the data, that responsibility is the parent's to initiate. However, that isn't explicitly stated.

Complexity in setup

The setup for being able to access and use the exposed data is not straight forward, nor is there any indication to the consumer in knowing how they're supposed ot interface with it (going back to implicit contracts).

Dependency injection

The alternative. Dependency injection. Vue gives us a very nice way of dependency injecting data or functions into a component via props. The benefit of this is that the component tells the consumer what props they expect and what they expect the prop to be or do.

Let's zoom back out a little bit and approach this multi-step module and the forms from a different angle. What does the user form actually care about? It takes in data via input fields, but it doesn't have a way of submitting that data. There is no "Submit" button in the form itself. However, when we think about the defineExpose() approach, we had decided that it should be the concern of the user form itself to process that data (i.e. submitUserForm()).

However, we want to better encapsulate that logic to the user form. So, now we have to think about how we want the parent of the user form to tell the user form that it is time to submit. To do this, let's define a prop in the user form; we'll call it registerSubmitFn.

User form

<template>
  <!-- User form fields -->
</template>

<script setup>
  import {ref} from 'vue';

  defineProps({
    /**
     * This registers the function that should be executed by the parent's form submission.
     * @type { function(function(): void): void }
     */
    registerSubmitFn: {
      type: Function,
      required: true
    }
  });

  const firstName = ref('');
  const lastName = ref('');
  const phoneNumber = ref('');

  const submitUserForm = () => {
    // Submission form logic (e.g. store in localStorage).
  };
</script>

We've now declared a required property called registerSubmitFn. Because we are using JavaScript, we only really have access to primitive data types, so I opted to add JSDocs to better explain the contract details. But let's break it down because it can be kind of consfusing.

Because the multi-step module is the one that executes something when the submit button is pressed, we want the user form to indicate what they expect to have happen. The user form tells the practice, "give me a setter function that I can call to register my submission details with you for later use".

The next step of the puzzle is for the user form to actually register their submission function with the multi-step module. We can do this in the component's onMounted lifecycle event.

User form

<template>
  <!-- User form fields -->
</template>

<script setup>
  import {ref, onMounted} from 'vue';

  const props = defineProps({
    /**
     * This registers the function that should be executed by the parent's form submission.
     * @type { function(function(): void): void }
     */
    registerSubmitFn: {
      type: Function,
      required: true
    }
  });

  /* User form state */
  // ...

  const submitUserForm = () => {
    // Submission form logic (e.g. store in localStorage).
  };

  onMounted(() => {
    // Register the submitUserForm function with the parent.
    props.registerSubmitFn(submitUserForm);
  });
</script>

We are one step closer! The user form component now explicitly tells any parent component exactly what it expects the parent's responsibilities to be. There is no magic. No arbitrary abstraction that results in encapsulation leak. We just engineered a form that is explicit, encapsulated and decoupled.

While we are at it, let's go ahead and show how we might update the emergency contact form as well.

Emergency contact form

<template>
  <!-- Emergency contact form fields -->
</template>

<script setup>
  import {ref, onMounted} from 'vue';

  const props = defineProps({
    /**
     * This registers the function that should be executed by the parent's form submission.
     * @type { function(function(): void): void }
     */
    registerSubmitFn: {
      type: Function,
      required: true
    }
  });

  /* Emergency contact form state */
  // ...

  const submitEmergencyContactForm = () => {
    // Submission form logic (e.g. store in localStorage).
  };

  onMounted(() => {
    // Register the submitEmergencyContactForm function with the parent.
    props.registerSubmitFn(submitEmergencyContactForm);
  });
</script>

Note: The fact that both the user form and the emergency contact form have the same kind of boilerplate might be an indication that there is opportunity for slight architectural improvements, but we'll save that for another day.

With both the forms now fixed up, let's take a look at the multi-step module again and make the necessary changes.

Multi-step module component

<template>
  <MultiStepModule>
    <template #header>User Onboarding</template>

    <template #body>
      <UserForm v-if="step === 1" :register-submit-fn="setFormSubmissionFn"/>
      <EmergencyContactForm v-else-if="step === 2" :register-submit-fn="setFormSubmissionFn"/>
    </template>

    <template #submissionButton>...</template>
  </MultiStepModule>
</template>

<script setup>
  import {ref} from 'vue';

  import UserForm from './UserForm.vue';
  import EmergencyContactForm from './EmergencyContactForm.vue';

  /* Multi-step module state */
  const step = ref(1);


  const currentFormSubmissionFn = ref(() => null);

  /* Registers the child's submission function for later use. */
  const setFormSubmissionFn = (fn) => {
    currentFormSubmissionFn.value = fn;
  }

  const submit = () => {
    // Call the function that the form has provided.
    currentFormSubmissionFn.value();

    if (step.value === 1) {
      step.value += 1;
    } else if (step.value === 2) {
      // module dismissal
    }
  };
</script>

With the change made to the forms the responsibility of the multi-step module becomes that of an orchestrator. It ensures that the contracts required of the children are met. In this case, that means because the module is using the user form, and because the user form is telling the module exactly what it requires, the module now has to cooperate with it contractually.

It's important to note that if the contract changes within the user form, then there will be a requirement for the parent to cooperate. However, this is expected and what we want.

Conclusion

As I mentioned prior to working through these two solutions, there are numerous ways of accomplishing the same kind of functionality, but while there might be a dozen ways of accomplishing the same functionality, there are generally only a few good ways of implementing a feature. If you're following the basic best practices of software engineering, then there shouldn't be any real issues. A little refactoring here and there as the needs changes is a good thing.

If you're interested in one of the most scalable implementations, and one that I would generally implement given this kind of feature set, you should try approaching this problems with a driver approach.

BeLike a programmer!