Vue updating computed object, or updating the original? - javascript

I'm using the computed() method to add some data to a ref() array of objects.
Using this computed array of objects works for reading data, for example using v-for, but it is ignored (nothing happens) when I'm trying to update the data, the example below shows the working vs not working code.
In the useCart composable (see code below), I created a computedCartItems which maps the cart and adds the totalPrice for each item. Now in my index.vue file, I try to increase the amount for a cartItem, this works if I loop over the cart using <div v-for="cartItem in cart"> but it is ignored when using the computed object <div v-for="cartItem in computedCartItems">
useCart.js
const useCart = () => {
const cart = ref([])
const computedCartItems = computed(() => {
return cart.value.map(cartItem => {
return {
...cartItem,
totalPrice: cartItem.amount * cartItem.price
}
})
})
return {
cart,
computedCartItems,
}
}
export default useCart
index.vue (not working, using computed 'computedCartItems' object)
<div v-for="cartItem in computedCartItems">
<div>
<div>{{ cartItem.name }}</div>
<button #click="onIncrement(cartItem)">+</button>
</div>
</div>
<script setup>
const { cart, computedCartItems } = useCart()
const onIncrement = ({ id }) => {
const shoppingCartItemIndex = computedCartItems.value.findIndex(item => item.id === id)
computedCartItems.value[shoppingCartItemIndex].amount++
}
</script>
index.vue (working, using original 'cart' object)
<div v-for="cartItem in cart">
<div>
<div>{{ cartItem.name }}</div>
<button #click="onIncrement(cartItem)">+</button>
</div>
</div>
<script setup>
const { cart, computedCartItems } = useCart()
const onIncrement = ({ id }) => {
const shoppingCartItemIndex = cart.value.findIndex(item => item.id === id)
cart.value[shoppingCartItemIndex].amount++
}
</script>

TLDR; you're updating values on a copy of your original object. They are not linked so the original object doesn't receive the updated value.
Detailed anwser
Computeds are readonly. They are derivated data and should not be updated.
Because this is javascript, you can update the object attributes by reference, but you really shouldn't, this is a bad practise leading to unclear side effects.
See the typescript type of computed:
export declare interface ComputedRef<T = any> extends WritableComputedRef<T> {
readonly value: T;
[ComputedRefSymbol]: true;
}
So myComputed.value is readonly and cannot be assigned another value. You can still do myComputed.value.myProperty = 'foo' but, as mentioned, this is a bad practise.
More information on this on the official documentation
A possible solution
Create the totalPrice composable for each item, not for the entire cart, and assign the computed inside your item object.
const useItem = (reactiveItem) => {
const totalPrice = computed(() => reactiveItem.amount * reactiveItem.price)
// Assign a new property in your item, which is the derived totalPrice
reactiveItem.totalPrice = totalPrice
return reactiveItem
}
const useCart = () => {
const cart = ref([])
// Export a custom function to include the item and make it reactive + use composable (saves the final client from doing it)
const addItem = (item) => {
cart.value.push(useItem(reactive(item)))
}
return { cart, addItem }
}
const { cart, addItem } = useCart()
function createItem() {
addItem({ amount: 5, price: 10 })
}
Check this online playground with a working example.
I'm sure there are other ways of doing it, this is only one. You could use watch to react for your cart changes for example.

The Core Issue
A computed ref is derived data: it represents your data in some way; you do not update it directly, you update its sources.
There is a section about this in the docs which explains the issue quite succinctly:
Avoid mutating computed value
The returned value from a computed property is derived state. Think of it as a temporary snapshot - every time the source state changes, a new snapshot is created. It does not make sense to mutate a snapshot, so a computed return value should be treated as read-only and never be mutated - instead, update the source state it depends on to trigger new computations.
In your non-working example, you are not trying to update the actual computed ref (which is not even possible; see the doc references at the end of the answer); you are updating properties of the ref's value, which you can -- but shouldn't -- do. However, aside from all the other problems, the computed will not update, as the total price is based on the original item in cart, not the one in the computed, meaning an update is never triggered (as cart is not changed).
If you instead modify the source ref (cart), the computed ref will update and the example will work:
<!-- Use `computedCartItems` here -->
<div v-for="cartItem in computedCartItems">
<div>
<div>{{ cartItem.name }}</div>
<button #click="onIncrement(cartItem)">+</button>
</div>
</div>
<script setup>
const { cart, computedCartItems } = useCart()
const onIncrement = ({ id }) => {
// Use `cart` here.
const shoppingCartItemIndex = cart.value.findIndex(item => item.id === id)
cart.value[shoppingCartItemIndex].amount++
}
</script>
A (Possibly) Better Way
While this works, it is quite possibly not the ideal way to go about solving your particular case. Every time an item is updated, the whole computed array and every item in it is recreated, which is very inefficient.
Instead, you can make the useCart composable only return the single cart ref along with some methods to manipulate the cart. You could do something like this:
import { ref, reactive, computed, readonly } from 'vue'
const useCart = () => {
const cart = ref([])
/**
Add a new item to the cart.
Makes the item reactive (so that there is a reactive source for computed properties),
adds the `totalPrice` computed property, and appends it to the cart array.
*/
const addItem = (item) => {
const reactiveItem = reactive(item)
reactiveItem.totalPrice = computed(() => reactiveItem.amount * reactiveItem.price)
cart.value.push(reactiveItem)
}
/**
Increase the amount of an item.
You could add all kinds of methods like these.
*/
const increaseAmount = (id) => {
const index = cart.value.findIndex((item) => item.id === id)
cart.value[index].amount += 1
}
return {
cart: readonly(cart), // So that the cart cannot be modified directly by the consumer.
addItem,
increaseAmount
}
}
const { cart, addItem, increaseAmount } = useCart()
addItem({ id: "1", amount: 5, price: 10 })
console.log(cart.value[0].totalPrice) // 50
Now the handling of the cart is done by the useCart composable, making things easier for the consumer by abstracting away internals. In addition to the gains mentioned above, this also means that the composable remains in control of its data, as the cart ref cannot just be modified. "Separation of concerns", etc.
Documentation References and Such
Vue Docs
Computed Properties - Vue.js Docs
The whole point of computed refs is that they update automatically based on their sources. You do not modify them directly, you modify their sources.
A computed property automatically tracks its reactive dependencies. Vue is aware that the computation of publishedBooksMessage depends on author.books, so it will update any bindings that depend on publishedBooksMessage when author.books changes.
You cannot assign a value to a regular computed ref.
Computed properties are by default getter-only. If you attempt to assign a new value to a computed property, you will receive a runtime warning. In the rare cases where you need a "writable" computed property, you can create one by providing both a getter and a setter.
I highly recommend reading the "Reactivity Fundamentals" section of the Vue Guide. See especially "Ref Unwrapping in Reactive Objects" for some insight on how the nesting of the computed ref inside the reactive works.
I also suggest going through the entire "Reactivity in Depth" page when you're ready. It gives you a grip on how the reactivity system actually works.
Other Links
VueUse is a great resource, both for many handy composables and for learning.

Related

Vue3 equivalent of vNode.computedInstance

I'm in the process of converting my app from Vue2 to Vue3, but have gotten stalled out on one aspect of my forms.
I'm using SFCs for form element components (FormInput, FormTextArea, FormCheckbox, etc.), and passing them into form container components (FormGroup, FormTab, etc) using slots, like so:
<ssi-form>
<ssi-form-tabs>
<ssi-form-tab title="tab1">
<ssi-form-input title="name" ... />
<ssi-form-checkbox title="active" ... />
</ssi-form-tab>
</ssi-form-tabs>
</ssi-form>
Those parent containers need to view some computed properties of the child form elements to pull error messages to the top.
In Vue2, I used the mounted lifecycle hook (with the options API) to read the slots and access the computed properties, like this:
mounted: function() {
const vm = this;
this.$slots.default.forEach((vNode) => {
const vueComponent = vNode.componentInstance;
vueComponent.$on("invalid", vm.onInvalid);
if (vueComponent.errorCount === undefined) return;
this.childFormElements.push(vueComponent);
});
},
Using this setup, I could grab the errorCount computed property from each child in the slot, so I could aggregate errors going up to the top level of the form.
Now that I'm switching to Vue3, it seems like componentInstance doesn't exist. I tried setting up similar logic using the onMounted directive, but when I access the slot elements, I can't find any way to see their errorCount computed property:
onMounted(() => {
slots.default().forEach((vNode) => {
console.log(vNode);
});
});
The logged object does not contain the computed property. I thought I found something useful when I read about defineExpose, but even after exposing the errorCount property, nothing comes up.
Here is the <script setup> from the SFC for the text input that I'm trying to work with:
<script setup lang="ts">
import { ref, defineProps, defineEmits, computed } from "vue";
let props = defineProps<{
label: string,
id: string,
modelValue: string|number,
type?: string,
description?: string,
min?: string|number,
max?: string|number,
pattern?: string,
message?: string
}>();
let emit = defineEmits(["input", "update:modelValue", "invalid"]);
let state = ref(null);
let error = ref("");
const input = ref(null);
function onInput(event: Event) {
validate();
emit('update:modelValue', event.target.value)
}
// methods
function validate() {
let inputText = input.value;
if (inputText === null) return;
inputText.checkValidity();
state.value = inputText.validity.valid;
error.value = inputText.validationMessage;
}
const errorCount = computed(() => {
return state.value === false ? 1 : 0;
});
defineExpose({errorCount})
</script>
So the question is - how can a parent component read the errorCount property from a component placed into a slot?
Internal properties shouldn't be used without a good reason, especially because they don't belong to public API and can change their behaviour or be removed without notice. A preferable solution is the one that can achieve the goal by means of public API.
Here the solution is to process slots in container component's render function. Vnode objects are the templates for rendered elements and they can be transformed at this point, e.g. a ref from the scope of container component can be added.
If the instances of child components need to be accessed to add event listeners, they can be added to a vnode at this point:
() => {
const tabChildren = slots.default?.() || [];
for (childVnode of tabChildren) {
// Specifically check against a list of components that need special treatment
if (childVnode.type === SsiFormInputComponent) {
childVnode.props = {
...childVnode.props,
ref: setTabChildRef,
onInvalid: invalidHandler,
};
}
}
return tabChildren;
}
Where setTabChildRef is ref function that maintains a collection of children refs, although it's not needed for the sole purpose of adding event listeners.
I believe this is not doable, since it has already been considered a bad practice since Vue 2.
defineExpose does help in such a situation, but since you said
I could grab the errorCount computed property from each child in the slot
maybe Provide / Inject could be a better approach?

Svelte app bug: uncompleted todos are not returned

I am working on a small todo app with Svelte. I list 10 todos from jsonplaceholder.
I want to count the todos whose completed property is equal to false:
const apiURL = "https://jsonplaceholder.typicode.com/todos";
const limit = 10;
import { onMount } from "svelte";
import TodoItem from './TodoItem.svelte';
let todos = [];
let unsolvedTodos = [];
onMount(() => {
getTodos();
});
const getTodos = () => {
fetch(`${apiURL}?&_limit=${limit}`)
.then(res => res.json())
.then((data) => todos = data);
}
const getUnsolvedTodos = () => {
unsolvedTodos = todos.filter(todo => {
return todo.completed === false;
})
}
$:console.log(unsolvedTodos);
As can be seen in this REPL, the unsolvedTodos array is empty.
EDIT
I got the list of unsolved todos and its length, but I can not use it in the header component.
const getTodos = () => {
fetch(`${apiURL}?&_limit=${limit}`)
.then(res => res.json())
.then((data) => todos = data)
.then(getUnsolvedTodos);
}
const getUnsolvedTodos = () => {
unsolvedTodos = todos.filter(todo => {
return todo.completed === false;
})
}
$:console.log(unsolvedTodos.length);
As visible in the REPL, using <span class="count">{unsolvedTodos.length}</span> throws an unsolvedTodos is not defined error, evan though I imported the ToDoList.
Where is my mistake?
Simply importing a component into another will not expose its properties. So you cannot do import TodoList from './TodoList.svelte'; and expect unsolvedTodos to be available in the Header. All you have available is the component TodoList. From your code, you seem to make the same mistake in TodoItem where you try to access the undefined variable todos.
The problem you are facing here is that you need to share data between two or more components that have no direct relation to each other (they are not parent and child). Sharing data between such components is usually solved in one of two ways:
Through the parent
The first solution is to move the state or data to the parent. In your case that would mean that the actual list of todos and all logic regarding adding, removing, toggling, etc... is stored in App.svelte and the other components become mere representation components to which you pass this list.
<script>
import TodoList from './TodoList.svelte';
let todos = []
// Here comes logic for fetching the list and changing the state
</script>
<TodoList todos={todos} />
As you see here, the App is responsible for keeping track of the todos, while the List component would only show them. If you want to add a new item to the list, you would do it here and not in TodoList Same if you change the state of an item, you have to bubble it all the way up to App.svelte and not change the state in TodoItem that one would be purely showing the current state.
This last part is a bit cumbersome so a better option might be to
use a store
Using [stores][1] you can define one single point to keep your state and then import that state into the components that need it, for example the TodoList would be:
<script>
import { todos } from './store.js';
import TodoItem from './TodoItem.svelte';
</script>
{#each $todos as todo}
<TodoItem todo={todo} />
{/each}
Similarly TodoItem could import this store and update the responding element in the list. (Best would be to use a custom store for this so all logic really now resides in the store object).
Don't use function to calculate but use filter code inline and prefix with $ to make it reactive
$: unsolvedTodos = todos.filter(todo => {
return todo.completed === false;
});
And use this value in template

Adding a variable to the store upon a functional component's initial render

Let's assume we want a Counter component <Counter startingValue={0}/> that allows us to specify the starting value in props and simply increases upon onClick.
Something like:
const Counter = (props: {startingValue: number}) => {
const dispatch = useDispatch();
const variable = useSelector(store => store.storedVariable);
return <p onClick={dispatch(() = > {storedVariable: variable})}>{variable}</p>;
}
Except, as it mounts, we'd like it to store its counting variable in the redux store (its value equal to the startingValue prop) and, as it unmounts, we'd like to delete the variable from the store.
Without the store, we could simply use the useState(props.startingValue) hook, however with the store it seems like we need constructors / equivalent.
A solution I see is to implement a useState(isInitialRender) variable and to create a variable in the store or not basing on an if instructor, albeit it looks like a bit convoluted solution to me.
I also get the feeling that I'm trying to do something against the react-redux philosophy.
This is the sort of thing that useEffect is intended for. If you specify an empty array for the second argument (the dependency array) then it will only run on the first render, and you can use the return function to remove it.
Here's roughly how to do it:
const Counter = (props: {startingValue: number}) => {
const dispatch = useDispatch();
const variable = useSelector(store => store.storedVariable);
useEffect(() => {
dispatch({type: 'store-starting-value', payload: startingValue})
return ()=>{
dispatch({type: 'clear-starting-value'})
}
}, []);
//...

Stateless functional component method never gets fresh data from Redux store, but others do

I got into some trouble.
I have a pretty common functional component connected to Redux via mapStateToProps:
const TableWrapper = ({ studentsSelection }) => {
const onComponentStateChanged = params =>
params.api.forEachNode(node =>
studentsSelection.selectedStudents.findIndex(student =>
student.number === node.data.number) >= 0 &&
node.setSelected(true)
);
const gridOptions = getGridOptions(onComponentStateChanged(), onSelection);
return (
<BootStrapTableWrapper>
<Table gridOptions={gridOptions} />
</BootStrapTableWrapper>
);
};
TableWrapper.propTypes = {
studentsSelection: PropTypes.shape({
selectedStudents: PropTypes.array
})
};
const mapStateToProps = state => ({
studentsSelection: state.gpCreationWizard.studentsSelection
});
export default connect(mapStateToProps)(TableWrapper);
Where getGridOption refers to this:
const getStudentsSelectionGridOptions = (onComponentStateChanged, updateStudentsSelectionSelectionAction) => ({
...studentsSelectionGridOptions,
onComponentStateChanged(props) {
onComponentStateChanged(props);
},
onRowSelected({node}) {
updateStudentsSelectionSelectionAction(node);
},
});
export default getStudentsSelectionGridOptions;
And resulted gridOptions are used for Ag-Grid table.
So let me explain the business function: I'm using Ag-Grid table, and when user select some students, they are added to the Redux store. Then I'm using the onComponentStateChanged to keep the selection on pagination. So if user changed the page, and the old data will be replaced by new one, I want the selection to be kept when he's gonna return back. But the problem is that onComponentStateChanged is always referring to the same studentsSelection.selectedStudents (but other methods and render receiving new data). So this is probably something about js scopes. Please, what should I do to make this function be using the new data, not the old one. I tried to wrap that one in anther function, but that didn't have any effect. The funny fact, that this works fine for class component and this.props referring. If I explained that not very clear, please ask about more details.
not sure what getGridOptions does... but I think the problem is, you are calling the method instead of passing the method, so change:
const gridOptions = getGridOptions(onComponentStateChanged(), onSelection);
to:
const gridOptions = getGridOptions(onComponentStateChanged, onSelection);
so onComponentStateChanged is sent as method and not the result of calling it

pushed items to array seem to be reactive to old object

I am facing an issue where after I push an object to an array it will be reactive to changes.
// actions.js
export const addToCart = ({ commit }) => {
commit('addToCart'); // properly commits to change the state
setTimeout(function () {
commit('resetToppings');
}, 10000);
};
// mutations.js
export const addToCart = (state) => {
state.cart.push(state.orderItem);
};
export const resetToppings = (state) => {
state.orderItem.toppings = [];
};
// state.js
cart: [],
orderItem: {
quantity: 1,
toppings: [],
},
The orderItem gets properly pushed to the cart, but 10 seconds later when I resetToppings, it resets the toppings inside the cart as well as in the orderItem.
How can I make sure that resetToppings does not mutate anything inside cart?
When you push state.orderItem you add a reference to it in the array. So when state.orderItem changes, the element inside the array changes, because it (the element inside the array) is actually still pointing to the same (state.orderItem) object.
You can push a shallow clone of the orderItem object instead:
// mutations.js
export const addToCart = (state) => {
state.cart.push({...state.orderItem});
};
This way what is added to the array is a different object.
Note: you can do:
state.cart.push({...state.orderItem});
But this will only work if you never remove/add elements from/to the toppings array directly after the addToCart is called. That is, if you call resetToppings before adding new elements to toppings (which will work because resetToppings assigns a new array).
If that's not always the case, I mean, if you sometimes edit the toppings array directly after the addToCart is called, you might want to clone it as well:
state.cart.push(Object.assign({...state.orderItem}, {toppings: [...state.orderItem.toppings]}});

Categories

Resources