Updating a Halogen child component on route change

I’m using the master branch of Halogen, so I’m not sure if this is related to the current released version. I have a parent component that reads the current path and displays the correct child component depending on the path. One such path is “/players/bklaric” that shows information about player “bklaric”. The parent component render function for this case is: render (Player nickname) = HH.div_ [ topBar, player nickname ], where player is the child component defined as:

data Query send = Init String send

...

component :: forall input left.
    String -> H.Component HH.HTML Query input Void (Async left)
component nickname = H.component
    { initialState: const Empty
    , render
    , eval
    , receiver: const Nothing
    , initializer: Just $ Init nickname unit
    , finalizer: Nothing
    }

player
    :: forall query children left
    .  String
    -> HH.ComponentHTML query (player :: Slot Unit | children) (Async left)
player nickname = HH.slot
    (SProxy :: SProxy "player") unit (component nickname) unit absurd

The eval case for the Init query constructor loads the player info and changes the component state to display the info. This works fine except in the case when the path changes so that the player component is the component to be rendered again, e.g. from path “/players/bklaric” to “/players/idunno”. My expectation was that the initializer would get called again and the component updated, but nothing happens. No request is sent to retrieve information for player “idunno” and the component stays the same. After fiddling around with it, I got it to work by specifying a receiver function and passing the nickname both as an input and to the initializer:

component :: forall left.
    String -> H.Component HH.HTML Query String Void (Async left)
component nickname = H.component
    { initialState: const Empty
    , render
    , eval
    , receiver: \nickname' -> Just $ Init nickname' unit
    , initializer: Just $ Init nickname unit
    , finalizer: Nothing
    }

player
    :: forall query children left
    .  String
    -> HH.ComponentHTML query (player :: Slot Unit | children) (Async left)
player nickname = HH.slot
    (SProxy :: SProxy "player") unit (component nickname) nickname absurd

This seems weird to me, because:

  1. I feel it breaks the declarativness of the library, because I have to take into account from which states can the parent component come into some other state.
  2. The nickname being passed as input seems to be ignored by the receiver when the component is first initialized, but is then ignored by the initializer when the component is already there.

Am I missing something here? Is there a better way to achieve this kind of use case?

1 Like

You’re already passing the nickname as Input, you don’t need to explicitly pass it as an argument to the component.
You should use initialState :: Input - > State, and then the initializer if you need some effectful action to populate part of the state

2 Likes

To follow on from @dariooddenino, some extra notes on initializers and receivers:

  1. There’s a section with practical notes on each of the major parts of Halogen components, including receivers and initializers, in the purescript-halogen-select documentation’s Halogen section: https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started/#a-whirlwind-tour-of-our-starter-component

  2. The section on input values from the Halogen documentation is pretty good


I think @dariooddenino’s advice is sound in general. Some more points:

  • If you need some initial state from your component, but you don’t need it to be effectful, then you should avoid the initializer and just use the initialState function. This will only run once when the component is instantiated.

  • If you need to do something effectful to set up the component, then use the initializer function with a query (usually named Initialize for convention, but whatever you want). But be aware that this, like the initial state function, will only run the very first time the component is instantiated.

  • If you need the component to change during its lifetime, then any values you want to continue to send to the component during that lifetime should be provided via the Input type and receiver function. Values passed as an argument to the component are like the initializer and initial state: they only get passed once. Values passed via the Input type will be updated and sent again every time the parent component re-renders. So it can be useful to provide an argument to a component for some setup purpose, but for any input that you might need to send again during the lifetime of the component should be done via the receiver function.

  • Note that a component’s “lifetime” as I’m talking about it here is from the moment you put the component in a slot until that slot is removed from the DOM. So long as the slot is hanging around, it will never re-initialize or re-run initialState or anything like that. All you can do is send inputs via the receiver. If you really want to re-initialize a component, you’re going to have to destroy it first, then re-mount it in a new slot.

I hope a few of these points help describe why this component isn’t behaving as you expect. Perhaps something like this is more what you’re looking for?

player :: forall left. H.Component HH.HTML Query String Void (Async left)
player  = 
  H.component
    { initialState: \input -> { nickname: input.nickname } -- or Nothing if you need empty states
    , render
    , eval
    , receiver: \input -> Just $ H.action $ DoSomething input
    , initializer: Nothing
    , finalizer: Nothing
    }

player' nickname = HH.slot (SProxy :: SProxy "player") unit player nickname absurd

@garyb @natefaubion please feel free to correct anything I’ve misrepresented here :wink:

2 Likes

Thank you @dariooddenino and @thomashoneyman for your ideas and suggestions. I feel I “get” how Halogen components work much better now. After trying suggested approaches out, I’ve decided to settle for the one outlined in my initial post for the time being. Passing the initial nickname through initialState seems intuitive, however, I end up having two query constructors, such as data Query send = Init send | Load String send, which basically do the same thing. This feels awkward to me, while the end result is still the same.

I think I’m misunderstanding something, because I really don’t see the need for two queries. Moreover, you’re passing the input as an argument, and then using unit as the input for HH.slot.
Without knowing more, this is how I’d do it:

type Input = String
type State = String
type Message = Void
data Query send = HandleInput Input send

component :: forall left. H.Component HH.HTML Query Input Message (Async left)
component = H.component
  { initialState: identity
  , render
  , eval
  , receiver: HE.input HandleInput
  , initializer: Nothing
  , finalizer: Nothing
  }

  render ...

  eval :: Query H.ComponentDSL State Query Message (Async left)
  eval (HandleInput i next) = next <$ do
    s <- H.get
    when (s /= i) $
      H.set i

player nickname = HH.slot (SProxy :: SProxy "player") unit component nickname absurd

I need to send an http request when the component is initialized and every time its input changes. If I set the initial state through its input, I can run an initializer that doesn’t take any arguments, but queries the initial state instead. This initializer would then do the same thing as HandleInput in your example. If I just set the initial state, but not the initializer, the component stays blank after it gets initialized, because no http request is sent to update component state.