maxence-charriere / go-app

A package to build progressive web apps with Go programming language and WebAssembly.
https://go-app.dev
MIT License
7.81k stars 357 forks source link

State management #436

Closed mar1n3r0 closed 2 years ago

mar1n3r0 commented 3 years ago

Looking for some broad feedback here.

I have explored further the idea of implementing component composition also known as slots as described here: https://github.com/maxence-charriere/go-app/issues/434.

While it definitely works in terms of simply compiling the HTML string the complexity it introduced in maintaining state between the root component and children components brought some questions up.

The simplicity of using Go structs as state management pattern is very appealing but at the same time easily shows its limitations once the app goes beyond the scope of one page - one component structure. Surely with this pattern we can communicate between pages with HTML5 API storage solutions as long as there is no sensitive data. But as long as we outgrow this pattern in-memory state management becomes inevitable.

This was an issue which most of JS frontend frameworks faced in their early stages and it evolved in to separate global state management modules and tools like Vuex for example.

Global state management solves all those problems when scaling from small to enterprise-like apps but comes at a cost with it's own caveats and extra stuff to think about and handle.

What are your thoughts on this ?

Do you think this will become a hot topic in the near future as the project evolves or it's beyond the scope of current usage requirements ?

Do you see the same pattern applied or a different technique Go can make use of to make it possible to scale apps ?

maxence-charriere commented 3 years ago

A technique I really loved was the react/flux model:

Having this kind of logic give those advantages:

Before I provided a flux implementation but I removed it since I felt it was opinionated and some people could come with a way more simple solution. Also having a flux model can be overkill for a simple scenario.

The current state of the package contains the basics to build a UI with go and does not try to do too much in order to let users the freedom to build on the top. I think its currently possible to have this kind of mechanism. Just it takes some work to build it yourself.

I'm currently working on bringing some basic layout components from scratch, so the door to reintroducing a Flux like implementation to handle the app state is not out of scope. Just if we do it, I must find an API that is simple enough to use and maybe provide it as a subpackage since it's not necessary to the core.

mar1n3r0 commented 3 years ago

Experimented a bit further. Here are my 5 cents based on the official example here: composing-components

Observances so far:

Keen to see more experiments and thoughts...

mar1n3r0 commented 3 years ago

Bumping this topic hope you don't mind trying to bridge all information from vecty, vugu and go-app in an effort to combine our knowledge and approaches.

https://github.com/hexops/vecty/issues/73#issuecomment-815567083

@guidorice the redux pattern is more about separating state from components. For example, if you have two different Components that rely on the same piece of data (state), then updating that data from any component should automatically re-render all components that rely on this data without you having to manually do that in each component.

That being said, Go's primitives might be good enough to have the user write a redux pattern themselves without any library use. This part, imo, can be debated. A few people, including myself, have experimented with making a redux library for vecty. The lack of generics in Go makes a standard redux library a little more messy to implement. So I am still on the hunt for something that integrates well with Go's style.

maxence-charriere commented 3 years ago

Using a redux pattern really depends on the app or user's needs. The package right now is flexible enough to work well with a given implementation.

In my opinion, a redux implementation is too specific for the scope of this package, and even if I take time to build one, there is no guarantee that my implementation would be better than someone else one, or fit all the redux needs, which would make the package even harder to maintain.

mar1n3r0 commented 3 years ago

Hey @maxence-charriere it was more about just talking about it among all 3 communities rather than taking specific actions.

One thing that seems certain is that it has bigger chance as an on demand sub-package based on needs. So far it's been kind of stalling as a topic in general in the Go wasm community so keeping it alive and talked about seems like a good step forward.

maxence-charriere commented 3 years ago

Ok, so I just gonna give feedback from my experience.

I went through a lot of UI paradigms in the past, started by MVC, MVP, MVVM, reactive programming, React/Flux, React/Redux.

I pretty enjoyed the 2 last ones and even made a Flux implementation in Go in the early versions of this package.

Right now I have 3 project built with go-app:

None of them use any of the quoted ways above. The reasons are it requires a lot of setups to implement them, and it felt overkill for my needs. Lofimusic and Go-app Docs a pretty simple, Murlok.io has API calls with data models. Still, I don't use it.

The way the code is made feels a little bit like each component is an independent program that handles UI, where each field is its arguments. There are some internal services (or store) that handle HTTP requests, caching, or else but it does not need to have a defined operational model. Each tries to solve a problem in the simplest way they can, staying the closest to Go vanilla (or stdlib) as possible.

In the end, I did not felt the need for those paradigms and my codebase is pretty simple. Components handle only UI, use a common HTTP request manager that requests and caches results. Components care only about results and errors. They compute only UI-related things while business logic is already handled in the data.

One of the problems I had with my previous experiences is that most of the time, I had to code each part of an app to fit the paradigm. Which resulted in adding complexity for something I felt afterward like syntax sugar gain. Every time you have to put yourself in the paradigm your using, with its hidden mechanisms, in order to finally make sense of what is happening. It happened often for me to come back in a codebase I built and been stupidly lost because of it.

What I like with my current apps is that even I go back on something I did not touch for a while, it is simple enough and a just need to read a single component to see what is happening, press command + click to go to the other part without anything hidden.

mar1n3r0 commented 3 years ago

Awesome, I am still using V5 btw :smile:

The limitations I see both in go-app and in all other mentioned frameworks is that state is internal to components rather than global. That makes it impossible to keep all of them up to date when it's shared between components and updated in each of them due to external calls. This is the reason I shared the post above which seems quite neat as a concept for enterprise-grade hundreds of components apps.

The reason I am cross-referencing is in the hope that we don't end up like the JS world spewing even more frameworks doing the same thing differently but rather communicate more and come up with common solutions to common problems.

maxence-charriere commented 3 years ago

A component can be built to react to a state.

For example, you could have a User struct that contains user data. Next to that, you could have 2 components that take the same User and display what they need to display. There are multiple ways for a component to get the same User data, but at the end, components shape what they need to display from that User struct (or state), which is external to the component. The component just needs a field that references it and do its stuff from that field.

mar1n3r0 commented 3 years ago

I actually saw this in your newer examples and tried to refactor based on it but due to the many async HTTP calls it didn't work out that well. Sometimes the dereferencing was happening before the reference was received. The more it grows the harder it was for me to keep track of what's being updated and rendered on time. More so I was getting conceptually lost at some point of complexity of the app. Plus I needed to pass the struct between components to have it available rather than having it globally available.

It is by no means a critique, go-app is amazing for what its purpose is. The whole point of discussion is food for thought rather than actual suggestions. State management store is something none of the go wasm ecosystem has done so it's interesting to lay out the ideas.

maxence-charriere commented 3 years ago

That is why I centralized HTTP request and caching in Murlok.io

Here is a snippet where I get an item from my API:

type conduitItem struct {
    app.Compo

    // Exported fields (or compo attr/params)
    Iconduit       api.GuideConduit
    ImostUsed      api.GuideConduit
    Iprimary       bool
    Isecondary     bool
    IhideChart     bool
    ItooltipFormat string

    conduit api.SoulbindConduit // <- the model (or state)
}

func (i *conduitItem) load(ctx app.Context) {
    url := fmt.Sprintf("/soulbinds/conduits/%v/rank/%v",
        i.Iconduit.Conduit.ID,
        i.Iconduit.Conduit.Rank,
    )
    remoteResource(ctx, url).         // <- the request call to get the model
        Cache(itemAndSpellTTL).
        Get(i.display)
}

func (i *conduitItem) display(ctx resourceContext) {
    if err := ctx.Err(); err != nil {
        app.Logf("%s", errors.New("loading conduit failed").
            Tag("url", ctx.URL).
            Wrap(err),
        )
        return
    }

    var conduit api.SoulbindConduit
    if err := ctx.DataTo(&conduit); err != nil {
        app.Logf("%s", errors.New("reading conduit data failed").
            Tag("url", ctx.URL).
            Wrap(err),
        )
        return
    }

    i.conduit = conduit
    i.Update()
}

The remoteResource() is doing the job of requesting/caching asynchronously and calling back display() on the UI goroutine. It uses the app.Context internally for async/dispatch operations.

If another component needs it, it is using the same func and gets the cached result that is already in memory. The asynchronous data fetching/caching is solved once in remoteResource(), each component gets whatever data they need with the same call and their code is kept only to focus on what they display.

There are a lot of ways to handle the model, but in my scenario, this was the most simple/straightforward way. No need for a store, just an HTTP request + memory cache. What is remaining is the UI that is handled in the component, the state is fetched from outside. The logic to get it lives in one go file that is used across the code base, available from a single func.

meling commented 3 years ago

I’m not a frontend dev, but I supervise some students that have been using overmindjs for state management. Thought I’d mention it here as overmindjs takes a different approach, which seems to have some benefits that could possibly translate to a Go variant as well?

Here is a blog about it.

mar1n3r0 commented 3 years ago

Thanks @meling that was a great read and a very neat approach to state management. I think it has great potential in the context of the Go ecosystem.

maxence-charriere commented 3 years ago

I don't have a specific solution but I'm considering adding a Mediator implementation that could dispatch a state across components.

mar1n3r0 commented 3 years ago

hexops/vecty#73

As discussed in the vecty community I think the general consensus is it should be a standalone optional library that is cross compatible with the whole ecosystem of Go wasm frameworks since state is always managed by native structs.

mar1n3r0 commented 3 years ago

I’m not a frontend dev, but I supervise some students that have been using overmindjs for state management. Thought I’d mention it here as overmindjs takes a different approach, which seems to have some benefits that could possibly translate to a Go variant as well?

Here is a blog about it.

I am thinking of porting this to Go if I have the time and make it compatible with all existing Go wasm frameworks. The fact that it uses single state tree seems very favorable towards the Go struct type. Anyone else keen on the idea ?

mar1n3r0 commented 3 years ago

If another component needs it, it is using the same func and gets the cached result that is already in memory. The asynchronous data fetching/caching is solved once in remoteResource(), each component gets whatever data they need with the same call and their code is kept only to focus on what they display.

@maxence-charriere This thought reminds me that by using a store only the initial component fetching data and altering the state can do the call. The rest that depend on the change can observe the change of the state properties(without each of them calling remoteResource()) they use and react accordingly. This seems as one big plus in favor of a store. The question that arises is how to make the engine that builds the app itself reactive given that the wasm compilation is a loop that runs only once automatically(manual re-renders afterwards) and is not able to react to external changes unlike the way modern JS frameworks do with their reactive APIs.

Quoting for clarification:

Go treats this as an application, meaning that you start a Go runtime, it runs, then exits and you can’t interact with it.

https://www.aaron-powell.com/posts/2019-02-06-golang-wasm-3-interacting-with-js-from-go/

So our biggest impediment towards a state management store is the non-reactive nature of how wasm is build in Go and the fact that we have no background process that can listen to changes and trigger a new build.

Do you see any potential ways to make wasm reactive?

maxence-charriere commented 3 years ago

I'm totally backtracking on this. One of my projects started to become enough complex for me to use a state manager.

I also talked with some of my frontend developer friends working on other platforms such as IOS and it seems that that same pattern is used there too.

Well just to say that after some research and some other input from @mar1n3r0 in #536, I came to an API that would be well integrated with this package.

Still experimenting with it but I will post something soon.

loafoe commented 3 years ago

@maxence-charriere @mar1n3r0 very interesting discussion on state management. I've read the different threads. I'm taking baby steps with the go-app framework. The first thing I wanted to try was to have a user log in using an OAuth2 flow but where would I store the state today? Would I need to call out to JS? Or do need to store some session / token in each component? The server component would have multiple instances running. Any psuedo code or pointer to an example appreciated.

maxence-charriere commented 3 years ago

Hey @loafoe. Right now in V8 i don’t provide anything to solve this problem.

If you take a look on the v9 branch, there is a lot if thing that are coming.

I just finished the states implementation and will communicate about it soon.

Context also have method to encrypt/decrypt values so you could use it and store it in something like local storage.

loafoe commented 3 years ago

@maxence-charriere ah great, I will check out the v9 branch. I need a UI for one of my apps and was really dreading getting back into the JS/Vue/React/Angular/Etc/Etc game before running into go-app, very refreshing approach!

mar1n3r0 commented 2 years ago

@maxence-charriere @mar1n3r0 very interesting discussion on state management. I've read the different threads. I'm taking baby steps with the go-app framework. The first thing I wanted to try was to have a user log in using an OAuth2 flow but where would I store the state today? Would I need to call out to JS? Or do need to store some session / token in each component? The server component would have multiple instances running. Any psuedo code or pointer to an example appreciated.

@loafoe Sorry for the late reply, I was just re-reading where we are at with state management and noticed you didn't get an answer so here it is.

For long lived refresh tokens it's advisable to store them on the server as https-only cookies which are same-domain protected and can not be tampered by the frontend. For any temporary tokens like access tokens you can store them in-memory via structs. Access tokens will be valid until you refresh at which point you use the cookie to get a new access token from the backend.

loafoe commented 2 years ago

@mar1n3r0 @maxence-charriere thank you for your feedback, I'm able to move forward implementing on the v9 release, awesome!