Text input not tracking state

halogen
#1

Hi all!

I just started using Halogen and I’m trying to prevent anything but numerical digits from being entered into an input text field.
When running the following code the state does change correctly, updating itself only when the input is all digits, but value of the input field does not seem to track the state when non-numerical characters are entered. I’m guessing that since the state isn’t changing on an invalid entry, the input field is not forced to re-render?

type State = String
data Action = CoerceInput String

render :: forall slots m. State -> HH.HTML (H.ComponentSlot HH.HTML slots m Action) Action
render state =
  HH.div_ [ HH.span_ [HH.text ("Current state is: " <> state)]
          , HH.input [ HP.value state
                     , HE.onValueInput (Just <<< CoerceInput)
                     ]
          ]

handleAction :: forall slots output m. Action -> H.HalogenM State Action slots output m Unit
handleAction = case _ of
  CoerceInput s -> if (all isDigit $ String.toCharArray s)
                     then put s
                     else pure unit

component :: forall query input output. H.Component HH.HTML query input output Aff
component = mkComponent
  { initialState : const ""
  , render
  , eval : mkEval $ defaultEval { handleAction = handleAction }
  }

main :: Effect Unit
main = launchAff_ do
  body <- awaitBody
  runUI component unit body

Can anyone let me know what I’m doing wrong here or how I can get around this problem?

Thanks in advance

#2

I had thought that H.put always causes a re-render, but @monoidmusician told me in Slack that it actually relies on object identity.

I’m aware this isn’t a lovely solution, and there is probably a better one (@garyb?) but you can get the behavior you want by changing the state and then changing it back to the old state. I did that by calling put twice like this:

oldState <- get
if (all isDigit $ String.toCharArray s) then
  put s
else
  put s *> put oldState

If you want the exact code I used to reproduce this and verify that it prevents you from typing anything other than a digit:

module Main where

import Prelude

import Data.Char.Unicode as Char
import Data.Foldable as Foldable
import Data.Maybe (Maybe(..))
import Data.String.CodeUnits as String
import Effect (Effect)
import Effect.Aff (Aff, launchAff_)
import Halogen as H
import Halogen.Aff as HA
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties as HP
import Halogen.VDom.Driver (runUI)

data Action = CoerceInput String

component :: forall query input output. H.Component HH.HTML query input output Aff
component = H.mkComponent
  { initialState: \_ -> ""
  , render: \state ->
      HH.div_ 
        [ HH.span_ 
            [ HH.text ("Current state is: " <> state)
            , HH.input 
                [ HP.value state
                , HE.onValueInput (Just <<< CoerceInput)
                ]
            ]
        ]
  , eval: H.mkEval $ H.defaultEval 
      { handleAction = case _ of 
          CoerceInput s -> do
            oldState <- H.get
            if (Foldable.all Char.isDigit $ String.toCharArray s) then
              H.put s
            else
              H.put s *> H.put oldState
      }
  }

main :: Effect Unit
main = launchAff_ do
  body <- HA.awaitBody
  runUI component unit body
#3

There are two things here, I’ll respond to the referential identity thing first: unfortunately, yes, there is a small hack used in there. The reason for that is, without it, every H.get would also cause a re-render - that rightly sounds bizzarre, but it’s a consequence of get, modify, put etc. all being implemented by the state function required for MonadState. Since we didn’t want to require Eq on state, we put a reference check in to try to get the best of both worlds.

Putting a different state then putting it back certainly is one option :smile:. I’d probably add a cycle :: Int (or some name like that) to the state and increment that each time instead of putting the exact unmodified state back, as that’d force a render. Alternatively flipping a Boolean on and off would work, just something to trivially modify the state.


As for what is actually being done here - I wouldn’t recommend it. Changing the value in an input when the user is typing in it will reset the input cursor position. This is fine if someone is only “typing forwards” in the input, but can become very annoying if they’re trying to edit a value.

A browser-natively supported alternative is to use the pattern property on an input which allows input to be constrained to values that match a regex pattern.

If pattern isn’t expressive enough, I’d recommend having the state contain Either String ValidatedValue for the property, and then store the string when it’s invalid and display a message about why the input is invalid instead. I tend to take this approach generally, as at least you can communicate more clearly what a valid value looks like. :slightly_smiling_face:

3 Likes
#4

What a great tip with pattern, I didn’t know that existed!

#5

I never knew about the pattern property. Cool! Thanks, both answers were very helpful.

#6

You might also find purescript-halogen-textcursor helpful.

Your current approach is using an uncontrolled component whereas the repo above is a controlled component.

1 Like
#7

I think I get how controlled vs uncontrolled components differ in React, but I’m not quite understanding how they work for Halogen components. Can you help point out the part in the repo you mentioned that makes it controlled?

#8

After looking at it more, I think I need to take that statement back. The advantage of that repo is that it already properly handles things like keyboard and mouse events. I thought it made it easier for you to restrict what values can be a part of the text field’s state, but I’m not so sure anymore.