Search code examples
javascripttypescriptsolid-js

How do I modify a member of an object in an array in a SolidJS Store using the SetStoreFunction, and have corresponding views automatically update?


I'm trying to modify an element of a SolidJS store using the SetStoreFunction (setExpressions), so that the View for the store's values are updated, e.g. when text is entered into a text field.

I originally thought the problem had to do with calling the SetStoreFunction itself, but the console logging in getExpressionSetValue shows that the setting is working. So the issue is with the updating of the View after the set takes place.

My expectation was that it would automatically be handled when using a store. I'm not sure if I'm using it wrong or what the problem is

Full Code:

import { createStore } from "solid-js/store";
import type { Component } from 'solid-js'
import { For } from 'solid-js';
import { TextField } from "@kobalte/core"
import { Button } from "@kobalte/core"

interface Expression {
  id: number,
  label: string,
  value: string,
}

interface ExpressionViewProps extends Expression {
  removeExpression: () => void
  setValue: (value:string) => void
}

const ExpressionView: Component<ExpressionViewProps> = (props) => {

  return (
    <div>
      <TextField.Root 
        value={props.value} //todo why doesn't this value update automatically?
        onChange={props.setValue}>
        <TextField.Label>
          {props.label}
        </TextField.Label>
        <br/>
        <TextField.Input />
        <TextField.Description />
        <TextField.ErrorMessage />
      </TextField.Root>
      <Button.Root class="button" onClick={props.removeExpression}>-</Button.Root>
    </div>
  )
}

const App: Component = () => {

  const [expressions, setExpressions] = createStore<Expression[]>([])
  let expressionId = 0

  const addExpression = () => {
    setExpressions(() => {
      const id = ++expressionId
      return [...expressions, {id: id, label: `Expression ${id}`, value: "inital value 0"}]
    })
  }

  const getRemoveExpression = (id: number) => {
    return () => {
      setExpressions(() =>
        expressions.filter(expression => expression.id != id)
      )
    }
  }

  const getExpressionSetValue = (id: number) => {
    return (value: string) => {
      console.log(value)
      setExpressions(expression => expression.id == id, 'value', value)
      console.log(expressions)
    }
  }

  return (
    <div>
      <For each={expressions}>{(expression) => {
        const expressionViewProps: ExpressionViewProps = {
          ...expression,
          removeExpression: getRemoveExpression(expression.id),
          setValue: getExpressionSetValue(expression.id),
        }
        return <ExpressionView {...expressionViewProps}/>
      }}</For>
      <Button.Root class="button" onClick={addExpression}>+</Button.Root>
  </div>
  )
}

export default App;


Update 8/8:

I tried switching to native UI elements instead of using Kobalte and now it works. E.g.:

const ExpressionView: Component<ExpressionViewProps> = (props) => {
  return (
    <div>
      <input
        value={props.value}
        onInput={(e) => props.setValue(e.target.value)}
      />
      <button type="button" onClick={() => props.removeExpression()}>-</button>
    </div>
  )
}

and

      <button type="button" onClick={addExpression}>+</button>

I still don't understand what's going on here and would like to fix the Kobalte version, because I'd like to use Kobalte


Solution

  • I haven't tested this out, but I think the following should help you:

    In general, there are two types of input components - "controlled" and "uncontrolled".

    • In controlled mode, user inputs trigger events, but state is only updated if your code updates it. So if a user starts typing, that triggers an input event, you update the value prop, and the input fields then updates to show a new value.
    • In uncontrolled mode, the input field updates with the new (user given) value, and then (or at the same time) triggers onInput. In this scenario, the the value prop and field can be out of sync: if the value prop never updates, the input doesn't care, it just shows the value the user types. (If, however, the value prop changes to a value different than the one currently shown in the input, then the input will update to the value prop).

    In solid, the behavior of the input element (and DOM elements in general) is uncontrolled. However, in kobalte, if you supply a value prop, then the component becomes controlled. In your case, I believe the value prop passed to ExpressionView never changes (I'll get to that in a second) so the display never changes either. Using a regular input doesn't have this issue, as uncontrolled mode works fine without needing the value prop to update.

    Now, as for the reason the value passed to ExpressionView is not updating, the problem is how you create the ExpressionViewProps

    <For each={expressions}>{(expression) => {
      const expressionViewProps: ExpressionViewProps = {
        ...expression,
        removeExpression: getRemoveExpression(expression.id),
        setValue: getExpressionSetValue(expression.id),
      }
      return <ExpressionView {...expressionViewProps}/>
    }}</For>
    

    In general, stores track where properties are accessed, and if the property is accessed inside a reactive scope, then that scope will rerun when the value in the store is updated. So, in your code, using ...expression accessed every property on the expression object, and assigns the key/value to the new object you're creating, but this isn't happening inside a reactive scope (the callback of For is not a reactive scope), so updates to the store won't cause the callback to rerun.

    What I suggest instead is to do

    return <ExpressionView removeExpression={getRemoveExpression(expression.id)} setValue={getExpressionSetValue(expression.id)} {...expression}/>
    

    Because solid's compiler will analyze the JSX (the compiler only analyzes jsx) and see that {...expression} is potentially reactive and create lazy property accesses (so the access will end up tracked where props.value is used) instead of a spread.