Search code examples
reactjsfrpevent-delegationpurescriptlenses

Adjacent component interaction in Thermite


A difficult test of UI design patterns turns out to be a simple task:

  • Create a generic component (in our case, a button)
  • use it in a parent component
  • get the child component to "actuate" effects in the parent component, or an adjacent sibling (if it can reach the parent, the parent should have no issue plumbing)

The design philosophy of Thermite is still a bit out of reach for me, but I think I understand how lenses and prisms can be used to combine Specs, but not how to invoke a parent's action.

This question was written for version 0.10.5, which may change by the reader's time.


The application being built will be a simple counter, where the increment button increases the count value, and the decrement button decreases it. We will do this by making a generic button component, then using multiple of them in the counter component. The design is as follows:

counter:
  - CounterState = { count :: Int
                   , incButton :: ButtonState
                   , decButton :: ButtonState
                   }
  - init = { count: 0
           , incButton: initButton
           , decButton: initButton
           }
  - CounterAction = Increment
                  | Decrement
                  | IncButton (ButtonAction CounterAction)
                  | DecButton (ButtonAction CounterAction)

button:
  - ButtonState = Unit
  - initButton = unit
  - ButtonAction parentAction = Clicked parentAction

In the button's ButtonAction, I'm stating that I need a parentAction to perform on Clicked. I've kept this as a type parameter to maintain a generic interface. However, this means we have to supply one from somewhere, so I've allowed a parameter in the button's Spec:

buttonSpec :: forall eff props parentAction
            . { _onClick :: parentAction }
           -> Spec eff ButtonState props (ButtonAction parentAction)
buttonSpec parentActions = T,simpleSpec performAction renderButton

This means I will use the _onClick action supplied here when I dispatch in my render:

renderButton :: Render ButtonState props (ButtonAction parentAction)
renderButton dispatch _ state _ =
  [ -- ... html stuff
  , button [onClick $ dispatch $ Clicked parentActions._onClick]
      [] -- with fill text etc
  ]

Now, the tricky part is integrating two buttonSpecs in a single counterSpec. For this, we leverage two lenses incButton and decButton, and two prisms _IncButton and _DecButton, which do the obvious state and action related tasks:

counterSpec :: forall eff props
             . Spec eff CounterState props CounterAction
counterSpec =
  T.simpleSpec performAction render
  where
    incButton = focus incButton _IncButton
              $ buttonSpec Increment
    decButton = focus decButton _DecButton
              $ buttonSpec Decrement

which we would use in the counter's performAction and render functions, using the lens and prism Thermite provides baked in:

  render :: Render CounterState props CounterAction
  render dispatch props state children =
    [ text $ "Count: " <> state.count
    ] <> (incButton ^. _render) dispatch props state children
      <> (decButton ^. _render) dispatch props state children

  performAction :: PerformAction eff CounterState props CounterAction
  performAction Increment _ _ =
    modifyState $ count %~ (\x -> x + 1)
  performAction Decrement _ _ =
    modifyState $ count %~ (\x -> x - 1)
  performAction action@(IncButton _) props state =
    (incButton ^. _performAction) action props state
  performAction action@(DecButton _) props state =
    (decButton ^. _performAction) action props state

This should be pretty straight forward. When we actually want to Increment or Decrement, we modify the parent's state. Otherwise, we look into the subcomponent-specific actions, but only just enough to tell who it should belong to! When it belongs to either the increment or decrement buttons, we pass the data to it.


That's the design for my ideal scenario - delegate the decision for "details" later, through polymorphism, and let composition handle plumbing. However, when testing this, react does not seem to dispatch the child component's parental actions. I'm not sure if this is how dispatch is intended to be designed, or what the issue actually is, but I have a git repo with a working minimal example of the error.


Solution

  • I've created a pull request on your GitHub repo. I think you're overcomplicating things by trying to pass the action to the Button. What's more in line with the Thermite way of doing things is to let the Button emit his action and then use a Prism in the parent component to map the Button's action into the parent's action space.

    So instead of having 4 actions on the parent you just have 2:

    data ParentAction = Increment | Decrement
    

    You then handle these the usual way by incrementing or decrementing a counter in your state. Now in order to trigger these ParentActions with your buttons you emit a simple Clicked Action from your buttons and use prisms to map these to Increment or Decrement:

    _IncButton :: Prism' CounterAction ButtonAction
    _IncButton = prism' (const Increment) $ case _ of
      Increment -> Just Clicked
      _ -> Nothing
    
    _DecButton :: Prism' CounterAction ButtonAction
    _DecButton = prism' (const Decrement) $ case _ of
      Decrement -> Just Clicked
      _ -> Nothing
    

    Now all that is left to use these Prism's to focus on the right Click actions:

    inc = T.focus incButton _IncButton $ buttonSpec {_value: "Increment"}
    dec = T.focus decButton _DecButton $ buttonSpec {_value: "Decrement"}
    

    And vóila!