piercefreeman/mountaineer

Controllers for «global» state

Closed this issue · 8 comments

Congrats on the launch @piercefreeman. I really like the concept of mixing a solid backend with a solid frontend option and glueing the two together, so that we get end-2-end, fully typed data exchange. Kudos!

While tinkering with a prototype, I was trying to build a simple user authentication. But the question arose, how I deal with a user_controller, which should be accessible on every page, to e.g. «Login» or get the current session, etc.

In a naive approach, I tried to link the user_controller with a view_path = "/app/user/CurrentUser.tsx" component, so that I could add this component to a header and have the currently logged in user displayed. Alas the compiler didn't like this.

Do you have a suggested approach already or is this work in progress?

Hey @stefnnn thanks for raising this.

Current Approach

The way I've been handling this so far is to subclass RenderBase on a global level and pass around some stub-state to each page that needs to materialize the HeaderComponent (for instance). Something like:

from mountaineer import RenderBase
from pydantic import BaseModel

class GlobalState(BaseModel):
    logged_in_username: str

class RootRender(RenderBase):
    global_state: GlobalState

And then within every controller do something like:

class ControllerRender(RootRender):
    value: str

class MyController(ControllerBase):
    def render(
       self,
       global_state: GlobalState = Depends(deps.get_global_state)
   ):
      return ControllerRender(global_state=global_state, value="my_val")

This should get you access to the state within your view files (consolidated into one file for github but should work just as well if you refactor).

const Header = (state: GlobalState) => {
  return <div>{state.logged_in_username}</div>
}

const Page = () => {
  const serverState = useServerState();
  return <Header state={serverState.global_state} />
}
export default Page;

Future Approach

I think this case might be a really good fit for our layout.tsx conventions, which can wrap multiple pages with one common layout. Right now layouts are assumed to be stateless so they don't have the notion of a backing controller. But I can imagine introducing a Controller that can link to a layout file and be automatically merged into the page state.

Then the useServerState() resolution syntax would vary depending on if in the main page component or the layout.

page.tsx
    /_server/useServer

    useServer() -> resolves local state

layout.tsx
   /_layout_server/useServer

  useServer() -> resolves state from the layout component

Do you think this would solve your use-case more cleanly?

Thanks for the swift reply! I was thinking about the same lines of using a base class for global state and I can see it work, but maybe not scale too much in complexity. But is suffices for now.

I'm not sure how far the approach of linking layouts to controllers would fly. In my top layout, I might have global state which is not really interrelated (account info, a common sidebar, etc.), so that leads to controllers which have to mix interrelated concepts. Also, I might still need to have access to such global state in a downstream page (i.e. the currently logged in users email).

I could try to sketch some more use cases and then see if some common usage patterns can be derived from that.

@stefnnn Some more examples would certainly be useful.

With the interrelated views you're laying out, you can nest layouts with different folders so this might help alleviate a bit of the consolidation of concerns. But I hear the main point. Dependency injection is probably going to be your friend regardless of the larger solution to shared layout state, since it lets you keep some central logic and use across all sub-pages.

An example of a login component in case it's helpful. Was an early exploration into plugins - not ready for production usage quite yet :)

Thanks @piercefreeman for the pointer to the login component. I managed to get it working in a mountaineer project: https://github.com/stefnnn/slopes

I'll come back with some further toughts on managing global state after playing around bit more, but great to have a basic example with rudimentary authentication working.

Hey @piercefreeman, I've been pondering some use cases, and indeed I think there are quite diverse cases, where a 1:1 link between a controller and a page (or a single layout), can be quite limiting.

Here's a few out of the top of my head:

  1. A newsletter subscription box, which is in a component that I can place throughout the site
  2. Show an excerpt from a list (e.g. FAQ, events,…) in a sidebar vs. full list page
  3. A language picker which should show up on every page (and changes global state)
  4. Multiple listing pages with different layouts, showing the same data
  5. A global notification icon, which should show up in different layouts

There are probably workarounds for all of these.

  1. could be solved by a simple POST request to a newsletter, but ideally I'd do that in-page.
  2. Dependency injection, but then the normal FAQ controller would need to expose itself as a dependency as well
  3. Global state, solved e.g. with dependency injection as you describe it above
  4. Subclassing the same controller, only changing the view property?
  5. Global state, solved e.g. with dependency injection as you describe it above

So these are probably not show-stoppers, but I'm just wondering, whether a 1:1 link (be that to a page, a layout or a component) could be limitting. Also, I don't understand the architecture well enough yet to understand, how difficult it would be, to more freely inject controllers m:n to components.

Putting together a general dump of my thoughts on this topic. Subject to change and evolve as more cases crop up.

Layouts

I do think extending the power of layouts will be necessary and useful going forward. They can already be nested, so you can specify a top-level layout and then get more specific with subsequent pages. If we give them the same power that regular controllers have, this would also allow for:

  • Initial data hydration via server side controllers
  • Definition of POST actions at a global/nonlocal level that would live within one controller but are effectively shared across the multiple pages that are wrapped by the same layout.

I think this would solve for your cases:

  • A language picker which should show up on every page
    • ie. sets a cookie at the global level to track the current language. This cookie could be extracted by a Dependency and shared within the layout render() and page render()
  • A global notification icon, which should show up in different layouts
    • Assumes it’s specified at the highest level, and sub-layouts can make different layout modifications
  • A newsletter subscription box
    • Assumes you could design a layout hierarchy that will position it in the same place on each page

Looking at the example that Next.js provides (which has conceptually been put through the paces with all their usage):

  • Layouts can perform asynchronous fetch logic on the initial view hydration, just like pages
  • There’s still a constraint to only have one page / multiple layouts to serialize each route.

This pretty closely aligns with how I’ve done things in Mountaineer as well, so it could provide a proof point that programmable layouts might be enough to solve 90% of these needs.

iFrames

For additional power, there's also the iframe approach. This covers uses where you need to inject a common component across multiple pages and they need to be in different positions (so layouts are not possible). In this case you could use a borderless iframe. Use the linkGenerator to calculate a dynamic link to the given GET endpoint:

<iframe src={linkGenerator.newsletterController({})} />

You could add these by convention in a “widgets” folder that doesn’t have any parent layouts so you don’t have to worry about affecting different views.

/views
  /app
    /components
    /home
    /widgets
       /widget1
          /page.tsx

This would solve the cases for:

  • Newsletter subscription box anywhere on the page
  • Excerpt from a list in the sidebar (ie. Provide url parameters to configure whether you want to show the full or partial list)

Embeddable Views

Certainly the most complex option on this list. Each view can be linked to a controller, but views themselves could import other views. This could support arbitrary nesting / imports of global components as if they’re just regular old React objects.

This works fine for side-effects since those are the same backend API whether they’re embedded or not. It's non-trivial for the initial data serialization and updates, since we’d need to figure out the initial React view properties that might affect state and then render these controllers individually. We could constrain them to not having injected arguments from the react components but this gets challenging quickly especially when we consider dynamically added components.

I’m sure this could be implemented with some combination of AST sniffing and artificial render() rollup objects. But it feels complex and would introduce too magic, not to mention the runtime slowdowns that it would induce.

@stefnnn Initial support for layout controllers was just merged in #104 and available as a prerelease within v0.5.0.dev1. With that introduced I'm going to close out this ticket as some combination of dependency injection, super-classing, and layouts seems to address the core use-cases here.

If you give it a try feel free to chime in another ticket if there are friction areas you're still hitting.

Awesome, thanks @piercefreeman I'll check it out!