Tips for implementing self-managed state in Vue.js data object

My approach in organizing my Vue application involves using classes to encapsulate data, manage their own state (edited, deleted, etc), and synchronize with the back-end system. However, this method seems to conflict with Vue in some respects.

To illustrate the issue with minimal code, let's consider a simplified version of my current setup.

Initial Strategy

Let's assume we have an object that defines the data as follows:

ModelState.ts

export public enum ModelState { saved, modified, deleted, new }

NameModel.ts

export public class NameModel {
  public name: string = 'New name'
  public state: ModelState = ModelState.modified

  public save(): Promise<boolean> {
    this.state = ModelState.saved
    return Promise.resolve(true);
  }
}

Now, I want to render this in a Vue component like this:

<script setup lang="ts">
import { NameModel } from 'NameModel'
import { ModelState } from 'ModelState'

export interface MyComponentProps{
  nameModel: NameModel
}
const props = defineProps<MyComponentProps>();

function saveClicked() {
  props.nameModel.save()
}
</script>

<template>
  <div>
    <input v-model="nameModel.name">
    <button 
      v-if="nameModel.state == ModelState.modified" 
      @click="saveClicked()">Save</button>
  </div>
</template>

The problem here is that although clicking the Save button updates the state property of NameModel, the UI doesn't reflect this change due to the proxy wrapping caused by passing NameModel as a prop.

Alternative Approaches

In an attempt to solve this, I made the state property reactive in the NameModel class:

NameModel.ts

import { ref, type Ref } from 'vue'
export public class NameModel {
  public name: string = 'New name'
  public state: Ref<ModelState> = ref(ModelState.modified)

  public save(): Promise<boolean> {
    this.state.value = ModelState.saved
    return Promise.resolve(true);
  }
}

Despite passing all unit tests, this modification resulted in a runtime error due to Vue unwrapping the reactive properties and changing the type of the object's state property from Ref<ModelState> to ModelState.

Revised Approach

To resolve the issue, I shifted the state management logic from the data model to the component. While this solution works, it compromises separation of concerns and requires duplicating the logic across components that mutate the state.

The updated code now looks like this:

export public class NameModel {
  public name: string = 'New name'
  public state: ModelState = ModelState.modified

  public save(): Promise<boolean> {
    return Promise.resolve(true);
  }
}
<script setup lang="ts">
import { NameModel } from 'NameModel'
import { ModelState } from 'ModelState'

export interface MyComponentProps{
  nameModel: NameModel
}
const props = defineProps<MyComponentProps>();

function saveClicked() {
  props.nameModel.save().then((success) => {
    if (success) props.nameModel.state = ModelState.saved
  })
}
</script>

<template>
  <div>
    <input v-model="nameModel.name">
    <button 
      v-if="nameModel.state == ModelState.modified" 
      @click="saveClicked()">Save</button>
  </div>
</template>

Although functional, this workaround feels inadequate. What might be a better approach for handling this?

Additional Details

While this example provides a basic demonstration, my actual implementation is more complex.

I have a Vue component with slots for various user experiences - view, edit, delete, add new. Despite being usable with any data model inheriting from a base class like PersistedEditable, this component now embeds the state management logic, leading to issues with distinct data models requiring different behaviors or permissions.

Furthermore, plans to implement WebSocket notifications for backend changes pose a challenge as updating the data model's state should ideally be handled within the model itself, not the Vue component.

Motivation (EDIT)

Adding more context to my query, I aim to leverage Vue's reactive UI state management capabilities, motivated by past positive experiences with Vue and its reactivity features.

However, my goal is to separate data state management from UI state management. For instance, when receiving a WebSocket message about data deletion, I want all displaying components to adjust based on this state change without duplicating logic across components.

Playgrounds (EDIT)

First attempt - not reactive

Second attempt - runtime error due to unwrapping

Third attempt - works but logic feels misplaced

Composable - suggested by @TheHiggsBroson

Renderless component - I think this is what @TheHiggsBroson suggested

Answer №1

This concept you've presented is truly intriguing. While I'm not certain if achieving this without integrating Vue functionality is possible, I appreciate the unique approach you're taking. Rather than being the typical critic on Stack Overflow, I understand your motivation and have a few potential solutions in mind.

It appears that your aim with these classes is to separate state logic from the component, packaging units of data separately from display logic. If my interpretation is correct, there are ways to address your concerns effectively.

Despite the limitations of using the vanilla class operator for this purpose, there are alternative options available, each with its own set of advantages and drawbacks:

Pinia

Pinia offers a "type safe, extensible, and modular by design" solution similar to a class. It enables you to contain both state and methods just like a class would. Whether through the options API or the composition API, Pinia ensures consistent state management across multiple instances throughout your codebase.

Composables

If you find Pinia's shared state setup inconvenient, consider utilizing composables with the Vue3 Composition API. This alternative allows each instance to maintain distinct memory, offering flexibility in managing states within different components.

Renderless components

Another option involves employing renderless components to store and expose state as needed. Though unconventional, this method grants creative freedom in structuring your codebase but may lack the same level of flexibility as composables.

I trust these suggestions provide some valuable insights for your project. Should you require further assistance, feel free to inquire!

EDIT

Proxy Class

A Proxy object could offer an alternative solution to achieve your desired outcome. By intercepting operations like property setting and getting, it can serve as a viable option if you prefer not to integrate a full-fledged state management library into your project.

Answer №2

If you stumble upon this discussion and want to adopt a similar approach, here is how I tackled it. Shoutout to @TheHiggsBroson for steering me in the right direction with their insightful response.

Customizable Solution

The crux of my solution lies in the Editable.ts file (featured below). It introduces

type Editable<Data> = {...} & Data
, which effectively extends any type Data to include a set of properties and methods responsible for managing the editable state.

Reactivity is achieved by defining a function that constructs a new reactive(Editable<Data>) instance while retaining a reference to it. Subsequently, this function appends methods to Editable<Data> that operate on the reactive proxy, thus ensuring all mutations to the editable are reactive.

The key sections of the newEditable function appear as follows (refer to the complete source code below):

type Editable<Data> = {
  state: string
  cancel(): void
} & Data

const newEditable = function(data: Data): Editable<Data> {
  var editable: Editable<Data> = reactive({ ...data, state }) as Editable<Data>

  editable.cancel = () => {
    // Reflect changes made on the reactive proxy for the editable
    // despite this cancel function being a method of Editable
    editable.state = 'cancelled'
  }
  
  return editable
}

Pinia Stores Implementation

I store all instances of Editable<Data> in pinia, where they are cached and undergo backend integration operations like retrieval, saving, deletion, etc., from the database.

Each store caters to a specific object type and exposes a utility function, such as usePerson(id?: string), which furnishes an editable person wrapper for an existing record or fabricates a novel person record if id is null.

If the editable person isn't present in pinia, the store retrieves the record from the backend and employs the newEditable() method discussed earlier to render it editable.

CrudPanel Composition

A versatile Vue component named CrudPanel orchestrates view, edit, delete functionalities via slots, exhibiting context-sensitive buttons contingent on the current state of the Editable<Data> received as a prop. These buttons either conceal or disable actions unfit for the present state, alongside swapping slots to exhibit various aspects of the record.

PersonCrud Component

PersonCrud serves as a Vue component encapsulating a CrudPanel and populating view, edit, delete slots with pertinent components corresponding to the record type showcased. This necessitates a mere 20-line implementation per record type that warrants modification.

PersonEdit Module

Conceptually, PersonEdit embodies an editor tailored for a person record. Propelling Editable<Person> as a prop ensures reactivity and sustains the editing state. Each categorical record type calls for up to 20-30 lines of code to manifest simple types proficiently.

Full Source Codes

Listed below are the complete source codes spanning across several files:

Editable.ts

import { reactive } from 'vue'

// Remaining code same as original snippet...

builders.ts

import { defineStore } from 'pinia'
// Remaining code same as original snippet...

CrudPanel.vue

<script setup lang="ts">
// Remaining code same as original snippet...
</script>

<template>
  // Remaining code same as original snippet...
</template>

BuilderCrud.vue

<script setup lang="ts">
// Remaining code same as original snippet...
</script>

<template>
  // Remaining code same as original snippet...
</template>

BuilderEdit.vue

<script setup lang="ts">
// Remaining code same as original snippet...
</script>

<template>
  // Remaining code same as original snippet...
</template>

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Struggling with the task of assigning a glTF item to a separate layer within Three.js

Exploring the use of layers in Three.js. This script includes a sphere, a triangle, and a glTF object (car). I activated a second layer: camera.layers.enable(1); Assigned the sphere, triangle, and glTF object to layer 1: car.layers.set( 1 ); sphere.lay ...

Tips for accessing the most recent embedded document added using the push() method

I'm having difficulty determining the feasibility of this situation. While using the mongoose blog example to illustrate, my specific use case is a bit more complex: var Comments = new Schema({ title : String , body : String , date ...

Error message: "The contract is not defined in thirdweb-dev/react

I am currently using thirdweb-dev/react library to interact with a smart contract. However, I am encountering an issue where the contract is showing up as undefined in my code along with the following error message: query.ts:444 Error: Could not ...

Discovering the specific style within an inspect-element and transferring it to a JavaScript file

As a novice in the realm of react/javascript, I encountered a situation where I successfully edited a specific style using the inspect element tool. However, despite my efforts, I struggled to locate the corresponding style within the Javascript file. Ha ...

JavaScript: Determine the wild decimal value produced by subtracting two decimal numbers

In the process of creating a service price tracking bot, I encountered an issue with displaying price decreases. Here is an example of what it currently looks like: Service price decreased from 0.10 to 0.05 If you buy right now, you could save <x> ...

Internet Explorer freezing when running selenium executeScript

Hey everyone, I've spent the past couple of days scouring the internet trying to find a solution to my modal dialog problem. There's a wealth of helpful information out there and everything works perfectly fine except for Internet Explorer. Speci ...

The code "Grunt server" was not recognized as a valid command in the

I recently set up Grunt in my project directory with the following command: npm install grunt However, when I tried to run Grunt server in my project directory, it returned a "command not found" error. Raj$ grunt server -bash: grunt: command not found ...

Tips for adding a background image using React refs?

Seeking assistance with setting a background for a specific div using react ref, this is my attempted code snippet: The component class structure as follows: import React from 'react'; import PropTypes from 'prop-types'; import class ...

Leveraging Next.js with TypeScript and babel-plugin-module-resolver for simplified import aliases

I am currently in the process of setting up a Next.js project with typescript. Despite following multiple guides, I have encountered an issue concerning import aliases. It's unclear whether this problem stems from my configuration or from Next.js its ...

Is it advisable to implement the modular pattern when creating a Node.js module?

These days, it's quite common to utilize the modular pattern when coding in JavaScript for web development. However, I've noticed that nodejs modules distributed on npm often do not follow this approach. Is there a specific reason why nodejs diff ...

The functionality of MaterializeCSS modals seems to be experiencing issues within an Angular2 (webpack) application

My goal is to display modals with a modal-trigger without it automatically popping up during application initialization. However, every time I start my application, the modal pops up instantly. Below is the code snippet from my component .ts file: import ...

Using AJAX to Send Requests to PHP

Embarking on my first ajax project, I believe I am close to resolving an issue but require some guidance. The webpage file below features an input field where users can enter their email address. Upon submission, the ajax doWork() function should trigger t ...

The state data is not being properly updated and is getting duplicated

While developing a loop to parse my API data, I encountered an issue where the values obtained were not being captured properly for dynamically loading corresponding components based on their characteristics. The problem arose after implementing useState() ...

How to Retrieve all Component Data Attributes in Vue

Currently, I'm working on a Vue plugin and utilizing a mixin to access all data properties of Vue components. The registration of the plugin is successful, and the mixin is functioning properly. Within the Vue.mixin, I've included a lifecycle h ...

What is the best method for sending a user to a different page when a webhook is triggered by an external API in

In my project using NextJS and the pages router, I encounter a scenario where a user initiates a process through a form that takes approximately 30 seconds to complete. This process is carried out by an external API over which I have no control. Once the p ...

Using Laravel and VueJS to send an array to the controller using Axios

I've developed a Laravel 5.4 application combined with VueJS 2.0 for the frontend. The issue I'm facing is that after populating the visitors array on the page and trying to post it to the controller, the data seems to disappear upon returning f ...

how to open a new tab using JavaScript with Selenium

My code is supposed to open a new window that goes from the login window to the main menu, module, reports, and finally the report name. The report name should be opened in the next tab. Issue: The report name is not opening in a new tab; it's openin ...

What is the best way to update the value of the nearest input field?

I am working with a table that has multiple rows, all structured in the same way. I have created a DIV element that can be clicked. When a user clicks on the DIV, I want the value of the input field in the same row to change. However, with the current co ...

Incompatibility Issues with TypeScript Function Overloading

In the process of setting up an NgRx store, I came across a pattern that I found myself using frequently: concatMap(action => of(action).pipe( withLatestFrom(this.store.pipe(select(fromBooks.getCollectionBookIds))) )), (found at the bottom ...

Encountering a Typescript issue while utilizing day classes from Mui pickers

Recently, I encountered an issue with my code that alters the selected day on a Mui datepicker. I came across a helpful solution in this discussion thread: MUI - Change specific day color in DatePicker. Although the solution worked perfectly before, afte ...