DEV Community

Cover image for Managing Complex Form State in Vue3/Nuxt3: Composition API and State Separation
Shintaro Takahashi
Shintaro Takahashi

Posted on

Managing Complex Form State in Vue3/Nuxt3: Composition API and State Separation

1. Introduction

Hello!
I'm developing internal products using generative AI at dip Corporation.

At our company, we're developing an application that generates and manages job postings using AI from sales notes and company information. For more details, please check out the following article!

https://dippeople.dip-net.jp/18941/

Recently, we replaced a simple frontend developed for PoC with Vue3/Nuxt3.
To implement complex form management, we adopted an architecture called the "Form/State Separation Pattern."
This made the state management of complex input forms more flexible and maintainable compared to before.
In this article, I'll explain the details of this pattern and how Vue3's Composition API made this pattern possible.

By the way, the reason for choosing Vue3/Nuxt3 is that another product in our department has adopted Nuxt3, so we had accumulated knowledge, and there were experienced engineers among our team members.

Prerequisites

2. The Challenge: Complex Forms and State Management

Issues Before the Replacement

Our job listing platforms like Baitoru have many input fields, and due to job advertising regulations, there are somewhat complex dependencies between fields. As a result, the constraints on each form field ≒ the amount of domain logic tends to be large.
Additionally, this application needed to update the form display through interactions with multiple APIs, not just the job posting registration API, but also APIs for generating job postings, searching for nearby stations, and more.

In summary, we faced the following challenges:

  1. Difficulty Managing Complex Forms

    • Domain logic and state management for numerous input fields
    • Display control based on field interdependencies
    • Complex validation logic
  2. Interactions with Multiple APIs

    • Not completed with just one submission
    • Need to call multiple APIs and reflect results in the UI

Target State

For the technical replacement, we set the following goals:

  • Separation of Concerns: Clear separation of data models, form logic, and UI
  • Testability: Easier testing through logic separation
  • Improved Maintainability: Code readability and maintainability through clear architecture

3. Solution: Introducing the Form/State Separation Pattern

Overall Architecture

Let's separate state management into form state and API request state.
We'll call this the Form/State Separation Pattern. (AI named it for us)
The Form layer manages form state, and the State layer manages API request state and other states.

Roles and Responsibilities of Each Layer

1. View (~xxx.vue)

  • Vue components
  • UI display
  • Accepting user operations (calling Form layer methods)

2. Form Layer (~Form.ts)

  • Maintains form state
  • Applies domain logic (logic itself is extracted as pure functions in separate files)
    • Form input value validation
    • Input value sanitization etc...
  • Describes UI logic
    • Error messages
    • Dynamically changing select box options etc...
  • Reflects values to the State layer

3. State Layer (~State.ts)

  • Defines and maintains data models
  • Data model-specific logic
    • Building API request parameters
    • Building download files etc..

4. Other Composables

  • Communication with external APIs
  • File download processing
  • Providing other common processing
    • Screen loading state management
    • Log transmission etc...

Data Flow

Here's an example when calling an API:

  1. User inputs into UI component
  2. Component calls Form layer method
  3. Form layer executes validation and processing
  4. Reflects value to State layer only on successful validation
  5. API request from API call composable
  6. State is updated based on response
[User Input] → [Form Layer] → [Validation] → [State Layer] → [API Call Composable] → [API Communication] → [State Update]
Enter fullscreen mode Exit fullscreen mode

4. Vue3 Composition API Supporting the Form/State Separation Pattern

Benefits of Composition API

Vue3's Composition API was an essential element for implementing the Form/State Separation Pattern. The following features particularly enabled the realization of this pattern.

1. Logic Extraction and Reusability

With Composition API, you can combine related logic and state into a single function. This is called a composable. A composable can be said to encapsulate stateful logic.
This made it possible to clearly separate Form layer and State layer logic/state from the View (component) and reuse them.

useBlogState.ts

// State layer composable
export const useBlogState = () => {
  // Data model
  const blog = ref({
    title: '',
    isPublished: false
  })

  return {
    blog
  }
}
Enter fullscreen mode Exit fullscreen mode

useBlogForm.ts

// Form layer composable
export const useBlogForm = (useBlogState: UseBlogState) => {
  const title = ref('')

  // This function is called from UI events
  const setTitle = (value: string) => {
    // ...processing logic
  }

  // ...

  return {
    title,
    setTitle
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Flexible Use of Reactivity System

Using APIs like ref and reactive, we can control reactivity more granularly. This enabled state management at different granularities in the Form layer and State layer.

// State management in Form layer
const title = ref('')
const titleValidationResult = ref({ valid: true, message: '' })

// ...validation processing

// Reflection to State layer
if (titleValidationResult.value.valid) {
  useBlogState.blog.value.title = title.value
}
Enter fullscreen mode Exit fullscreen mode

3. Affinity with Type System

Affinity with TypeScript improved, making it easier to write type-safe code. In particular, type definitions of dependencies became clear, making error detection during development easier.

// Type definitions
export type UseBlogState = ReturnType<typeof useBlogState>
export type UseBlogForm = ReturnType<typeof useBlogForm>

// Type-safe dependency injection
export const useBlogForm = (useBlogState: UseBlogState) => {
  // Type-safe implementation
}
Enter fullscreen mode Exit fullscreen mode

Why This Pattern Was Difficult with Options API

When trying to implement the Form/State Separation Pattern with Options API, the following challenges arise:

// Implementation example with Options API
export default {
  data() {
    return {
      // Form layer and State layer states are mixed
      title: '',
      titleValidationResult: { valid: true, message: '' },
      blog: {
        title: ''
      }
    }
  },
  methods: {
    // Logic is also mixed
    setTitle(value) {
      this.title = value
      this.validateTitle()
      if (this.titleValidationResult.valid) {
        this.blog.title = value
      }
    },
    validateTitle() {
      // Validation logic
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With Options API:

  • Difficult to separate logic (data and methods are tightly coupled)
  • Difficult to reuse code (mixins are insufficient)
  • Weak type inference (due to object-based structure)

5. Practicing the Form/State Separation Pattern: Details and Benefits

Let's look at a more specific implementation using a blog post form as an example.

Details of State Management with Composables

State Management in State Layer

The State layer maintains data in a format close to API requests.

useBlogState.ts

export const useBlogState = () => {
  const blog = ref({
    title: '',
    content: '',
    isPublished: false
  })

  // Initialization processing
  const initialize = () => {
    blog.value = {
      title: '',
      content: '',
      isPublished: false
    }
  }

  return {
    blog,
    initialize
  }
}
Enter fullscreen mode Exit fullscreen mode

State Management in Form Layer

The Form layer defines reactive variables for managing UI state.

useBlogForm.ts

export const useBlogForm = (useBlogState: UseBlogState) => {
  /************************************************
   * Title
   ************************************************/
  const title = ref('')

  // Validation
  const titleValidationResult = computed(() => {
    const rules = [validationRules.isRequired]
    return {
      valid: rules.every(rule => rule(title.value).valid)
        && validationRules.isOverMaxLength(title.value, 15).valid,
      message: [
        ...rules
          .map(rule => rule(title.value).message ?? ''),
        validationRules.isOverMaxLength(title.value, 15).message ?? '',
      ].filter(message => message),
    }
  })

  // Set value and reflect to State
  const setTitle = (value: string) => {
    title.value = value

    if (titleValidationResult.value.valid) {
      useBlogState.blog.value.title = value
    }
  }

  /************************************************
   * Content
   ************************************************/
  const content = ref('')
  // ...other form fields


  /************************************************
   * Overall validation result and UI display
   ************************************************/
  const isFormValid = computed(()=> {
    return titleValidationResult.value.valid
    && contentValidationResult.value.valid
    && // ...other form fields
  })

  // ...other UI display related

  /**
   * Initialize entire form state
   */
  const initialize = () => {
    title.value = ''
    content.value = ''
    // ...other form fields
  }

  return {
    title,
    setTitle,
    // ...other form fields
    initialize
  }
}
Enter fullscreen mode Exit fullscreen mode

Clarifying Dependencies

In the Form/State Separation Pattern, dependencies between composables become explicit.

index.vue

// Explicit definition of dependencies
const blogState = useBlogState()
provide('useBlogState', blogState)

const blogForm = useBlogForm(blogState) // Dependencies are clear through arguments
provide('useBlogForm', blogForm)
Enter fullscreen mode Exit fullscreen mode

BlogForm.vue

// Usage example in components
const useBlogForm = inject<UseBlogForm>('useBlogForm')!
Enter fullscreen mode Exit fullscreen mode

Improved Testability

Since each layer is independent, unit tests are easier to write.

// State layer test
describe('useBlogState', () => {
  it('should initialize with initialize', () => {
    const { blog, initialize } = useBlogState()
    blog.value.title = 'test'
    initialize()
    expect(blog.value.title).toBe('')
  })
})

// Form layer test
describe('useBlogForm', () => {
  it('should fail validation if title is empty', () => {
    const mockState = useBlogState()
    const { setTitle, titleValidationResult } = useBlogForm(mockState)
    setTitle('')
    expect(titleValidationResult.value.valid).toBe(false)
  })
})
Enter fullscreen mode Exit fullscreen mode

Specific Benefits

  1. Clear Separation of Concerns

    • Clear responsibilities for UI display, UI state and logic, and data model
    • Improved testability
    • Improved maintainability
  2. Robust Validation

    • Explicit form input validation
    • Maintaining clean data models
  3. Flexible Component Design

    • Reusable UI parts
    • Consistent UI structure

Application: Managing Multiple States

For example, suppose you want not only to send form content to the server but also to download it as a CSV file. Additionally, the API request parameters sent to the server and the number and data format of CSV items to download are slightly different.

Even in such cases, with the Form/State Separation Pattern, you can add useBlogCsvState.ts for CSV download to the State layer to separate state management from API requests and consolidate CSV-specific data conversion logic.

The challenge of different data formats between core DBs and CSV files actually used in business is a common problem in DX settings, isn't it? The Form/State Separation Pattern can benefit such situations as well.

6. Considerations for the Form/State Separation Pattern

Learning Cost and Boilerplate

  1. Learning Cost

    • Deviation from standard Vue/Nuxt patterns
    • Time required for newcomers to understand
  2. Boilerplate Code

    • Duplicate code due to Form/State separation
    • Need to generate many files even for small features

Trade-offs When Applying to Small Features

For small features or simple forms, this pattern may be excessive. It's good to consider application based on judgment criteria such as:

  • Form complexity (number of input fields, interdependencies)
  • Complexity of validation rules
  • Need for reusability
  • Scale of team development

7. Summary and Future Outlook

Concrete Improvement Effects from Replacement

  1. Improved Code Quality

    • Improved type safety with TypeScript
    • Improved testability
    • Improved maintainability
  2. Improved Development Efficiency

    • Improved code readability through clear architecture
    • Reusable components and logic
    • Smoother team development

Projects Where This Architecture Fits

This architecture is particularly effective in the following situations:

  1. Applications with Complex Form Processing

    • Forms with multi-stage validation
    • Cases requiring complex data mapping with APIs
  2. Applications Requiring Strict Data Integrity

    • Need for clear separation of user input and internal models
    • Cases with complex input validation
  3. Projects with Long-term Maintenance Planned

    • Improved maintainability through clear responsibility distribution
    • Emphasis on component reusability

Future Improvements

Gradual Introduction of State Management Libraries

  • Consideration of state management libraries like Pinia
  • Centralized management of global state

Through the adoption of this architecture, we achieved improved development efficiency and code quality, but there is still room for further improvement. We plan to continue with continuous improvement and optimization.

Top comments (0)