Skip to content

Add React-style "props", VProp.#1503

Open
dmjio wants to merge 6 commits intomasterfrom
vprops
Open

Add React-style "props", VProp.#1503
dmjio wants to merge 6 commits intomasterfrom
vprops

Conversation

@dmjio
Copy link
Copy Markdown
Owner

@dmjio dmjio commented Apr 24, 2026

This adds the VProp constructor to the miso View DSL. The goal of this PR is to emulate the React "props" feature.

Always redraw if useProps is True. This must be opted into currently.

  • Parameterize View by parent
  • Add VProp constructor case
  • Add useProps, _componentUseProps
  • Implement VProps case for rendering HTML

dmjio and others added 5 commits April 23, 2026 23:56
This adds the `VProp` constructor to the miso `View` DSL.
The goal of this PR is to emulate the React "props" feature.

- https://react.dev/learn/passing-props-to-a-component

Always redraw if `useProps` is `True`. This must be opted into currently.

- [x] Parameterize `View` by `parent`
- [x] Add `VProp` constructor case
- [x] Add `useProps`, `_componentUseProps`
Copy link
Copy Markdown

@mtamc mtamc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Component using VProp does not seem to get re-rendered with latest values. It seems stuck on its initial value. I haven't dug into the code to figure out why. See my minimal usage attempt: https://github.com/mtamc/miso-sampler/blob/experiment/vprops/app/Main.hs#L76

Thought: I'm still experimenting with the new props, I'll make a follow-up post when I'm done figuring out all I can do with it.

Comment thread src/Miso/Types.hs Outdated
Comment thread src/Miso/Types.hs Outdated
@mtamc
Copy link
Copy Markdown

mtamc commented Apr 24, 2026

After trying this out, this API in its current form would not be appealing for me to use over a ParentToChild binding at the moment. I'll explain why with an example of React component props passing:

counters-2026-04-24_15.39.53.webm

The renders an array of counters, and each counter has a "Foo" stateful child whose color depends on the parent counter value, and the position of the child's associated counter (the first counter's color is fixed to blue).

import { useState } from 'react';

export default function App () {
  const [counters, setCounters] = useState([0, 0, 0, 0]);
  return (
    <div>
      <h1>🍜 Miso counters</h1>
      <div>
        Counter total: {counters.reduce((a, b) => a + b, 0)}
      </div>
      {counters.map((counter, i) => (
        <Counter
          key={"counter-" + i}
          i={i}
          counter={counter}
          onChange={(delta) => {
            setCounters((old) => old.map((c, j) => (j === i ? c + delta : c)));
          }}
        />
      ))}
    </div>
  );
};

const Counter = ({ i, counter, onChange }) => (
  <div>
    <button onClick={() => onChange(1)}>+</button>
    <button onClick={() => onChange(-1)}>-</button>
    {" | "}
    {counter}
    {" | "}
    <Foo
      attrs={
        i === 0 ? {style: {color: "blue"}}
          : counter < 0 ? {style: {color: "red"}}
          : counter > 0 ? {style: {color: "green"}}
          : {}
      }
    />
  </div>
);

const Foo = ({ attrs }) => {
  const [timesClicked, setTimesClicked] = useState(0);
  return (
    <span {...attrs} onClick={() => setTimesClicked((old) => old + 1)}>
      {" | (Foo here! Foo was clicked "}
      {timesClicked}
      {" times)"}
      {" | "}
    </span>
  );
};

You can try it online: https://codesandbox.io/p/devbox/dry-breeze-t37n6f

Notice how we are declaring Foo's attrs prop using some logic that depends on the parent model value counter and static information i. This is a pretty common pattern, and maybe my bias is speaking but it feels quite natural.

This is how I was able to replicate that with Miso.props and it feels too verbose and indirect right now:

data AppModel = AppModel {_counters :: [Int]} deriving (Eq, Show)
counters = lens (\(AppModel cs) -> cs) (\(AppModel _) cs -> AppModel cs)

data Action = OnChange Int Int deriving (Show, Eq)

app :: App AppModel Action
app = (component (AppModel [0, 0, 0, 0]) updateModel viewModel)

updateModel :: Action -> Effect parent AppModel Action
updateModel (OnChange i delta) = compose (at i) counters %= fmap (+ delta)

viewModel :: AppModel -> View parent AppModel Action
viewModel x =
    H.div_ [] $
        H.h1_ [] ["🍜 Miso counters"]
            : H.div_
                []
                [ "Counter total: "
                , text . ms . show . sum $ x ^. counters
                ]
            : zipWith viewCounter [0 ..] (x ^. counters)

viewCounter :: Int -> Int -> View parent AppModel Action
viewCounter i counter =
    H.div_
        []
        [ H.button_ [E.onClick $ OnChange i 1] ["+"]
        , H.button_ [E.onClick $ OnChange i (-1)] ["-"]
        , " | "
        , text $ ms counter
        , ("foo-" <> ms i) +> fooComponent i
        ]

instance HasField "fooAttrs" AppModel (Int -> [Attribute FooAction]) where
    getField mdl i
        | i == 0 = [CSS.style_ [CSS.color CSS.blue]]
        | (mdl ^. counters) !! i < 0 = [CSS.style_ [CSS.color CSS.red]]
        | (mdl ^. counters) !! i > 0 = [CSS.style_ [CSS.color CSS.black]]
        | otherwise = []

-- Child component Foo

data Foo = Foo {_timesClicked :: Int} deriving (Show, Eq)
timesClicked = lens (\(Foo tc) -> tc) (\(Foo _) tc -> Foo tc)

data FooAction = FooClicked deriving (Show, Eq)

fooComponent ::
    (HasField "fooAttrs" parent (Int -> [Attribute FooAction])) =>
    Int -> Component parent Foo FooAction
fooComponent i = (component (Foo 0) (pure $ timesClicked += 1) (viewFoo i))
    { useProps = True }

viewFoo ::
    (HasField "fooAttrs" parent (Int -> [Attribute FooAction])) =>
    Int -> Foo -> View parent Foo FooAction
viewFoo i mdl = props @"fooAttrs" $ \iToFooAttrs ->
    H.span_
        (iToFooAttrs i)
        [ " | (Foo here! Foo was clicked "
        , text $ ms $ show (mdl ^. timesClicked)
        , " times)"
        ]

(Another test case I wanted to try was make attrs also depend not just on i and counter, but also on some part of a grandparent model, passed to the parent, then drilled down into the child. I wanted to test if the grandparent value would get stale, but I couldn't, because it seems all values still get stale, currently.)

Here's how I would achieve the same with reactive bindings and #1501, the function signatures are much cleaner, and the logic to determine the attrs is next to where we mount the child component, similar to React:

viewCounter :: Int -> Int -> View parent AppModel Action
viewCounter i counter =
    H.div_
        []
        [ H.button_ [E.onClick $ OnChange i 1] ["+"]
        , H.button_ [E.onClick $ OnChange i (-1)] ["-"]
        , " | "
        , text $ ms counter
        , ("foo-" <> ms i)
            +> fooComponent
                ( \mdl ->
                    if
                        | i == 0 -> [CSS.style_ [CSS.color CSS.blue]]
                        | (mdl ^. counters) !! i < 0 -> [CSS.style_ [CSS.color CSS.red]]
                        | (mdl ^. counters) !! i > 0 -> [CSS.style_ [CSS.color CSS.black]]
                        | otherwise -> []
                )
        ]

-- Child component Foo

data Foo = Foo
    { _timesClicked :: Int
    , _attrs :: [Attribute FooAction]
    }
    deriving (Show, Eq)
timesClicked = lens (\(Foo tc _) -> tc) (\foo tc -> foo{_timesClicked = tc})
attrs = lens (\(Foo _ as) -> as) (\foo as -> foo{_attrs = as})

data FooAction = FooClicked deriving (Show, Eq)

fooComponent ::
    (parent -> [Attribute FooAction]) ->
    Component parent Foo FooAction
fooComponent parentToAttrs =
    (component (Foo 0 []) (pure $ timesClicked += 1) viewFoo)
        { bindings = [ParentToChild parentToAttrs (attrs .~)]
        }

viewFoo :: Foo -> View parent Foo FooAction
viewFoo mdl =
    H.span_
        (mdl ^. attrs)
        [ " | (Foo here! Foo was clicked "
        , text . ms . show $ mdl ^. timesClicked
        , " times)"
        ]

This is already quite nice, but here's what this would look like in the dream scenario:

viewCounter :: Int -> Int -> View parent AppModel Action
viewCounter i counter =
    H.div_
        []
        [ H.button_ [E.onClick $ OnChange i 1] ["+"]
        , H.button_ [E.onClick $ OnChange i (-1)] ["-"]
        , " | "
        , text $ ms counter
        , ("foo-" <> ms i)
            +> fooComponent
                (if | i == 0 -> [CSS.style_ [CSS.color CSS.blue]]
                    | counter < 0 -> [CSS.style_ [CSS.color CSS.red]]
                    | counter > 0 -> [CSS.style_ [CSS.color CSS.black]]
                    | otherwise -> []
                )
        ]

-- Child component Foo

data Foo = Foo { _timesClicked :: Int } deriving (Show, Eq)
timesClicked = lens (\(Foo tc) -> tc) (\foo tc -> foo{_timesClicked = tc})

data FooAction = FooClicked deriving (Show, Eq)

fooComponent :: [Attribute FooAction] -> Component parent Foo FooAction
fooComponent attrs = component (Foo 0) (pure $ timesClicked += 1) (viewFoo attrs)

viewFoo :: [Attribute FooAction] -> Foo -> View parent Foo FooAction
viewFoo attrs mdl =
    H.span_
        attrs
        [ " | (Foo here! Foo was clicked "
        , text . ms . show $ mdl ^. timesClicked
        , " times)"
        ]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants