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:
-
Difficulty Managing Complex Forms
- Domain logic and state management for numerous input fields
- Display control based on field interdependencies
- Complex validation logic
-
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:
- User inputs into UI component
- Component calls Form layer method
- Form layer executes validation and processing
- Reflects value to State layer only on successful validation
- API request from API call composable
- State is updated based on response
[User Input] → [Form Layer] → [Validation] → [State Layer] → [API Call Composable] → [API Communication] → [State Update]
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
}
}
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
// ...
}
}
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
}
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
}
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
}
}
}
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
}
}
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
}
}
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)
BlogForm.vue
// Usage example in components
const useBlogForm = inject<UseBlogForm>('useBlogForm')!
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)
})
})
Specific Benefits
-
Clear Separation of Concerns
- Clear responsibilities for UI display, UI state and logic, and data model
- Improved testability
- Improved maintainability
-
Robust Validation
- Explicit form input validation
- Maintaining clean data models
-
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
-
Learning Cost
- Deviation from standard Vue/Nuxt patterns
- Time required for newcomers to understand
-
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
-
Improved Code Quality
- Improved type safety with TypeScript
- Improved testability
- Improved maintainability
-
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:
-
Applications with Complex Form Processing
- Forms with multi-stage validation
- Cases requiring complex data mapping with APIs
-
Applications Requiring Strict Data Integrity
- Need for clear separation of user input and internal models
- Cases with complex input validation
-
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)