Comment by 0x6c6f6c

16 days ago

I like TEA but don't fully grasp how it scales for apps that may have reusable components or sufficiently complex pages. Is there an agreed-upon way(s) to deal with this? I know state is a big NO so it seems a bit at odds, but also does this essentially mean all Elm apps are just a global Redux and React app with no effects? Curious about more details to what you enjoy and how you like to work in Elm. Links also perfectly fine too.

Not the person you were asking, but I shall share my experienc..

>how it scales for apps that may have reusable components or sufficiently complex

It is a lot of boilerplate, but it is mechanical, straightforward changes. I think it is entirely possible to automate it (not even using LLM).

> state..

I have used ELM ports to interact with JS and localstorage/indexeddb.

In my experience, the biggest problem in Elm is how unintuitive TEA's docs are. In some ways, Elm really feels like Haskell -- you want to avoid creating "stateful" modules and you prefer stateless modules.

For example, https://harmont.dev's app (landing page is not Elm) has a component to render a pipeline graph called DagGraph. In React, you'd do something like

  function DagGraph() {
    const steps = useState<Jobs[]>([]);
    const selectedJobIdx = useState<number | null>(null);
    return ...;
  }

In elm, I lift this up (I will keep using React syntax for the sake of wider-audience readability)

  function DagGraph({ steps, selectedJobIdx } : IDagGraphModel) {
    return ...;
  }

This allows for better testability of the graph view. The parent then injects the model

  function PipelinePage({ graphMode, selectedPipeline }: IPipelineModel) {
    return (
      graphMode === GraphMode.DagView
        ? <DagGraph steps={selectedPipeline.steps} selectedJobIdx={selectedPipeline.selectedStep} />
        : <TableView steps={selectedPipeline.steps} selectedJobIdx={selectedPipeline.selectedStep} />
     );
  }

Now -- we need to bubble up the selected pipeline index based on when the user clicks a node in the DAG view. I personally prefer to do this completely orthogonally to the view rendering pipeline (which remains mostly pure). I create a new model, and define the message relationships to it:

  namespace PipelineState {
    interface Model {
      steps: Step[];
      selectedStep: number | null;
    };

    interface ISelectStepMessage { stepIdx: number };
    interface IUnselectStepMessage;

    type Message = ISelectStepMessage | IUnselectStepMessage;

    function update(m: Model, msg: Message): Model { return...; }
  }

  // main model
  interface Model {
    pipeline: PipelineState.Model,
  }

  type Message = PipelineState.Message | SomeOtherMessage | ...;

  function update(m: Model, msg: Message): Model {
    switch (msg) {
      case PipelineState.Message as msg:
        return update(m, msg);
      ...
    }
  }

And this seems to work quite well. Personally, I really like this architecture, because it enables me to think about state and UI separately. One of the really big gripes I have with React is how most components end up with `useEffect` and `useState`, and then immediately become untestable. Elm literally doesn't allow for that.

At the end of the day, there's a trade-off -- my logic is no longer localized to the "component" relevant to rendering the pipeline DAG. I never have one file open editing Elm code, but I'm a vim user so it doesn't bother me. In some ways, semantically, I think it makes sense to split the "state backend" of your UI from your UI, in my opinion.

Most of my app states are represented as state machines (coming from embedded this is quite natural) and UI is represented as pure transformations of that state.

With Elm, it really feels like you reason about state and UI separately, and I actually prefer that, both for testability and that's just how my brain works.