Global state management for Halogen.
Install halogen-store
with Spago:
spago install halogen-store
This library provides global state management for Halogen applications. A global or central state can help when many components need access to the same information, and threading those values through components via their inputs is either tedious or leads to an explosion of unnecessary fields in state.
Writing applications with halogen-store
comes down to three major steps, detailed in the next three sections:
First, we should create a central state for our application. This is called a "store" by convention.
module Basic.Store where
type Store = { count :: Int }
initialStore :: Store
initialStore = { count: 0 }
In the same module we'll create an action type that represents an update to our store. This action type is similar to the action type you define in each of your Halogen components.
data Action = Increment | Decrement
Finally, we should create a reducer: a function of type store -> action -> store
that updates our central state when it receives an action. It's somewhat similar to the handleAction
function you define in your Halogen components, but it can't perform effects.
reduce :: Store -> Action -> Store
reduce store = case _ of
Increment -> store { count = store.count + 1 }
Decrement -> store { count = store.count - 1 }
If you need to perform effects before or after updating the central state, then you can do that in the Halogen component which is performing the update.
As a brief aside: actions introduce some boilerplate to your application. If you want to stay bare-bones, then you can define your action type as a function Store -> Store
, and then you can implement your reducer as function application:
type Action = Store -> Store
reduce :: Store -> Action -> Store
reduce store k = k store
This lets you write arbitrary store -> store
functions and send them to your central state.
We can now use our store in our Halogen components. A component with access to the central store is called a "connected" component; a connected component can read, update, and subscribe to the store.
A connected component requires a MonadStore
constraint which specifies the store, action, and underlying monad types. We already defined our store and action types in the Basic.Store
module, so we can reuse that in our component definition:
import Basic.Store as BS
component
:: forall q i o m
. MonadStore BS.Action BS.Store m
=> H.Component q i o m
The MonadStore
class provides three methods:
getStore
retrieves the current value of the store.updateStore
applies an action to the store to produce a new store, using our reducer.emitSelected
produces anEmitter
from thehalogen-subscriptions
library that will notify subscribers of the store's new value when it changes.
We can now use these methods anywhere we write HalogenM
code -- for instanec, in our handleAction
function:
import Basic.Store as BS
import Halogen.Store.Monad (updateStore)
handleAction = case _ of
Clicked ->
-- This will increment our central store's count.
updateStore BS.Increment
In practice it's common to send actions to the store with updateStore
, but it's somewhat rare to use getStore
or emitSelected
. That's because there's an easy way to subscribe a component to the store and always keep its state in sync with the central store: the connect
function.
A component that uses connect
function will receive the central state as part of its component input. That means it can use the central state with initialState
and stay subscribed to all future state updates via the receiver. This is the easiest way to stay in sync with the store over time.
For example, the component below will receive the store's current value when it initializes and will receive the store's new value each time it changes:
import Basic.Store as BS
import Data.Maybe (Maybe(..))
import Halogen as H
import Halogen.Store.Connect (Connected, connect)
import Halogen.Store.Select (selectAll)
type Input = Unit
type State = { count :: Int }
deriveState :: Connected BS.Store Input -> State
deriveState { context, input } = { count: context.count }
data Action
= Receive (Connected BS.Store Input)
component
:: forall query input output m
. MonadStore BS.Action BS.Store m
=> H.Component query input output m
component = connect selectAll $ H.mkComponent
{ initialState: deriveState
, render: \{ count } -> ...
, eval: H.mkEval $ H.defaultEval
{ handleAction = handleAction
, receive = Just <<< Receive
}
}
where
handleAction = case _ of
Receive input ->
H.put $ deriveState input
In the real world we can't afford to update every connected component any time the central state changes; this would be incredibly inefficient. Instead, we want to only updated connected components when the bit of state they are concerned with has changed.
We can use a Selector
to retrieve part of our central state and only be notified when the state we've selected has changed. In the previous example we used selectAll
to just grab the entire store, but usually we'd write our own selector.
Imagine that our store actually contained dozens of fields in addition to the count
field we've implemented, but we only want to subscribe to that field. Let's do that by adjusting our component from the last section.
import Halogen.Store.Select (Selector, selectEq)
-- We are no longer connected to the entire store; we're only connected to
-- the `count` field, which is of type `Int` for our new context.
type Context = Int
deriveState :: Connected Context Input -> State
deriveState { context, input } = { count: context }
selectCount :: Selector BS.Store Context
selectCount = selectEq \store -> store.count
data Action
= Receive (Connected Context Input)
component
:: forall query input output m
. MonadStore BS.Action BS.Store m
=> H.Component query input output m
component = connect selectCount $ H.mkComponent
{ initialState: deriveState
, ...
}
Now, even if other fields in our state are regularly changing, this component will only receive new input when the count
field has changed.
When we run our application we'll need to satisfy our MonadStore
constraints. Halogen components must always be run using the Aff
monad, but our application needs to use a monad that supports MonadStore
.
To solve this issue, we can use the StoreT
transformer as the monad for our application, and then use runStoreT
to transform it into Aff
. (You're also welcome to define your own application monad, though I'd recommend defining it in terms of StoreT
.)
We don't need to explicitly use StoreT
in our component types; all we need to do is call runStoreT
and supply an initial store, our reducer, and the component that requires the store. Let's see it in action:
module Main where
import Prelude
import Basic.Counter as Counter
import Basic.Store as BS
import Effect (Effect)
import Effect.Aff (launchAff_)
import Halogen.Aff as HA
import Halogen.Store.Monad (runStoreT)
import Halogen.VDom.Driver (runUI)
main :: Effect Unit
main = launchAff_ do
body <- HA.awaitBody
root <- runStoreT BS.initialStore BS.reduce Counter.component
runUI root unit body
If you want to write your component with Halogen Hooks ,then you can use the useSelector
hook to access the store.
module Main where
import Prelude
import Halogen.Hooks as Hooks
import Halogen.Store.Select (selectAll)
import Halogen.Store.UseSelector (useSelector)
component
:: forall q i o m
. MonadStore BS.Action BS.Store m
=> H.Component q i o m
component = Hooks.component \_ _ -> Hooks.do
context <- useSelector selectAll
Hooks.pure do
...
Unlike connect
, the context returned by useSelector
has the type Maybe store
because the hook does not have access to the store before it is initialized.