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?
Related
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.
I am trying to change the state in a class component by using setState.
More specific I have a table, and I want to edit/update one of its elements. For this case, I am passing the indeces to the handleTableFieldOnChange function for the position of the value in the array.
Since I know that I should not mutate the state, I used an external library to deep copy the tables array/list.
The deep copy and the new value assignment works. The deep copy worked also with the JSON.parse(JSON.stringify(this.state.tables)); alternative.
Problem: For some reason the this.setState(...) does not change the tables value.
I do know the setState is asynchronous, this is why I used the callback and within it, the console.log(...) to check the updated value.
console.log(...) still emits the old value.
private handleTableFieldOnChange(val: boolean | string | number | [number, string], tblRowIndex: number, tblIndex: number, tblColINdex: number) {
const cloneDeep = require('lodash.clonedeep');
const newTables = cloneDeep(this.state.tables);
if (newTables && newTables[tblIndex] && newTables[tblIndex].items ) {
newTables[tblIndex].items![tblRowIndex][tblColINdex].value = val;
}
this.setState( {tables: newTables}, () => {
console.log(this.state.tables)
})
}
state: State = {
tables: [],
report: this.props.report,
};
constructor(props: DetailProp, state: State) {
super(props, state);
this.initFieldsAndTabels();
}
private initFieldsAndTabels() {
if (this.state.report && this.state.report.extraction_items) {
this.state.tables = [];
this.state.report.extraction_items.forEach((extractionItems) => {
this.state.tables.push(extractionItems);
});
}
}
The code in handleTableFieldOnChange looks fine to me.
However in initFieldsAndTabels you are applying push on state directly instead of calling setState which may probably cause the issues:
this.state.report.extraction_items.forEach((extractionItems) => {
this.state.tables.push(extractionItems); //#HERE
});
Also as React.Component docs state you should not call setState in constructor (you are calling initFieldsAndTabels in constructor. Instead you could use componentDidMount.
P.S. If you want to add those extraction items in the constructor then you need something like this:
constructor(props) {
super(props);
// method should return a new array/object, but not modify state
const tables = this.initFieldsAndTabels();
this.state = {
tables,
}
}
I have the following components:
const ParentComponent: React.FC = () => {
const networkRef: any = useRef();
// Somewhere in the code, I call this
networkRef.current.filter(["id0, id1, id2"]);
return (
...
<VisNetwork
ref={networkRef}
/>
...
)
}
export default ParentComponent;
interface Props {
ref: any;
}
const VisNetwork: React.FC<Props> = forwardRef((props: Props, ref) => {
useImperativeHandle(ref, () => ({
filter(items: any) {
setFilterNodes(items);
nView.refresh();
}
}));
const [filterNodes, setFilterNodes] = useState<any[]>([]);
const filterNodesRef = useRef(filterNodes);
useEffect(() => {
filterNodesRef.current = filterNodes;
}, [filterNodes]);
...
// Some code to create the network (concentrate on the nodesView filter method)
const [nView, setNView] = useState<DataView>();
const nodesView = new DataView(nodes, {
filter: (n: any) => {
if (filterNodesRef.current.includes(n.id)) {
return true;
}
return false;
}
})
setNView(nodesView);
const network = new vis.Network(container, {nodes: nodesView, edges: edgesView}, options);
});
export default VisNetwork;
WHen I call network.current.filter([...]), it will set the filterNodes state. Also, it should set the filterNodesRef inside the useEffect.
However, the filterNodesRef.current remains to be empty array.
But when I call network.current.filter([...]) the second time, only then the filterNodesRef.current got the value and the DataView was able to filter.
Why is it like this? I thought the useRef.current will always contain the latest value.
I finally solved this by calling the refresh() method inside the useEffect instead of the filter() method:
useEffect(() => {
filterNodesRef.current = filterNodes;
nView.refresh();
}, [filterNodes]);
Settings the .current of a reference does not notify the component about the changes. There must be some other reason why it works the second time.
From https://reactjs.org/docs/hooks-reference.html#useref
Keep in mind that useRef doesn’t notify you when its content changes. Mutating the .current property doesn’t cause a re-render. If you want to run some code when React attaches or detaches a ref to a DOM node, you may want to use a callback ref instead.
You may want to use useState, as this does rerender the component.
Two more things
I'm not really sure what networkRef.current.filter(["id0, id1, id2"]) is. Typescript does complain when I try to do ['a'].filter(['a']) and I've never seen this, so are you sure this is what you wanted to do?
If you're passing references around there's probably a better way to do it. Maybe consider re-thinking the relations between your components. Are you doing this because you need access to networkRef inside multiple components? If yes, you might want to look at providers.
If this does not answer your question, write a comment (about something specific please) and I'll be happy to try and help you with it :)
Yes, useRef.current contains latest value, but your filterNodesRef.current in a useEffect that's why you get empty array in initial render.
Initial render of VisNetwork the filterNodes is an empty array ==> filterNodesRef.current remains empty. Because setFilterNodes(items); is asyn function => event you set it in useImperativeHandle it will be updated in second render.
In useImperativeHandle you set setFilterNodes(items); ==> filterNodes is updated and the VisNetwork re-render ==> useEffect is triggered ==> filterNodesRef.current is set to new filterNodes
Let's try this:
....
const filterNodesRef = useRef(filterNodes);
useImperativeHandle(ref, () => ({
filter(items: any) {
filterNodesRef.current = filterNodes;
setFilterNodes(items);
nView.refresh();
}
}));
...
I am retrieving DOM nodes of my child components by passing a callback into its ref prop as shown.
Parent component:
setElementRef = (name, element) => {
if (element) {
this.setState({
...this.state,
menuItems: {
...this.state.menuItems,
[name]: prop,
},
});
}
};
render() {
return <Child refCallback={(node) => this.setElementRef("child", node)} />
}
Child component:
render() {
return (
<div ref={this.props.refCallback}/>
}
The information in the nodes such as getBoundingClientRect() are needed. However, I am unable to setState as it exceeds the maximum update depth when multiple child components trigger the callback. Is there a way of storing multiple DOM nodes in the state, or should I avoid setting the state completely and use a class variable instead?
Theoertically said, reference is not state. Therefore you should not use state to store component reference.
In your case, you just need to create object on class to keep your references (Unline setState it won't trigger re-render, and it will be accessible from within your component, or you can pass it as prop)
childRefs = {}
setElementRef = (name, element) => {
this.childRefs.current[name] = element;
}
// ... and use it like this
someOperation = () => {
const { child } = this.childRefs;
if (child) {
const rect = child.getBoundingClientRect();
}
}
Original answer - to be used with functional components and hooks
If you need to work with references, it is recommended to use useRef (It allows you to update it's value without rerendering component) to keep actual reference, or to keep one single object, and just set properties with your reference. Then you can work with those refernces in callbacks or useEffect.
Is it considered bad practice to store classes in the state? I've read that the state should be easily serializable objects and classes break this convention.
I'm creating a web application that uses React/Redux with a set of backing models to collect information from the user.
So for example I have the following model:
class Person {
constructor(params = {}) {
this.firstName = params.firstName || '';
this.lastName = params.lastName || '';
this.gender = params.gender || '';
this.dateOfBirth = params.dateOfBirth || '';
this.stateOfResidence = params.stateOfResidence || '';
this.email = params.email || '';
}
validateField(args) {
// remove for brevity
}
valid(args) {
// remove for brevity
}
}
The object is added to the store by issuing the following actionCreator:
export function addPerson(args = {}) {
return (dispatch) => {
dispatch({
type: 'ADD_PERSON',
obj: new Person(args)
});
};
}
The appropriate person reducer sticks it into the state so I can get at it from the rest of the application.
This way when I grab the object from the state I can run it's validate and valid prototype functions to see if the model is valid and react appropriately.
Does this break React/Redux convention? What would be another way to approach this problem? What are the potential problems I might run into down the road with the above approach? Right now can't foresee any problems... but I also don't have much experience with React/Redux (~4 months)
Edit:
I should add that I only mutate the object state through the reducers. I am careful to not mutate state elsewhere in the application.
Yes, it's generally considered an anti-pattern, because it breaks the ability to do things like time-travel debugging (one of Redux's core selling points). See the Redux FAQ at for further details.
If you do want to have more of an object-like facade over your plain data, you may want to look into something like redux-orm.
so i think my code help someone need to find a way can be solve transform class to function by difficult function doesn't support protypes let some my code can fix them and easy with
import React from 'react'
import PropTypes from 'prop-types'
const Link = ({ active, children, onClick }) => (
<button
onClick={onClick}
disabled={active}
style={{
marginLeft: '4px',
}}
>
{children}
</button>
)
Link.propTypes = {
active: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onClick: PropTypes.func.isRequired
}
export default Link