RFC: Signal-based view state management

One area with great opportunities for improved convenience and type safety through code generation is view state management, i.e. handling state variables that are reflected in the view’s URL.

View state parameters

As a simple example, let’s take a view in views/employees/{employeeId}.tsx. Based on this structure, Hilla can deduct that the view has a required state parameter named employeeId. This can be automatically used to generate a view state function for this view, named e.g. useEmployeeViewState with the return type {employeeId: Signal<string>}.

export default function EmployeeView() {
  const {employeeId} = useEmployeeViewState();

  return <>
    This is the view for {employeeId}.
  </>
}

Let’s furthermore say that the view has a search box for filtering the various listings shown on that view and we want that filter value as a query parameter named filter in the URL. Hilla cannot automatically know about this parameter but if we define it in the exported ViewConfig as parameters: {filter: {type: 'query'}}, then it could be included in the useEmployeeViewState return that would become {employeeId: Signal<string>, filter: Signal<string | undefined>} since query parameters are optional by default.

The value of those signals will always be updated if the URL in the browser changes. Application logic can also update the signal value and then the URL will be updated accordingly.

export const config: ViewConfig = {
  parameters: {filter: {type: 'query'}}
}

export default function EmployeeView() {
  const {employeeId, filter} = useEmployeeViewState();

  return <>
    <TextField value={filter.value} onValueChanged={(e) => filter.value = e.detail.value}  />
    This is the view for {employeeId}, searching for {filter}.
  </>
}

Note that everything in this example is fully type safe. The editor will provide auto completion suggestions for the property names from useEmployeeViewState. There will be a compilation error if you change something that causes either parameter name to change. The type of the filter reflects the possibility that the value is undefined which means that TypeScript will notice if the value is used in a way that wouldn’t work.

URL generation

Just reading and writing signal values is not enough when you want to provide a link to a view or forward the user to that view through some action. For this, you instead want to generate a URL going to some specific state in some specific view and use that URL either together with <Link> or useNavigate(). Based on the same view configuration, the framework could also generate a urlEmployeeView function that takes some desired view state, e.g. {employeeId: 'foo', filter: 'bar'}, and returns a URL leading to the specific view with the specific state, e.g. employees/foo?filter=bar. For the employee view with only one required parameter, there could also be a shorthand in the form of urlEmployeeView('foo').

Everything is type safe here as well so that there’s auto completion for parameter names and a typo in any name will lead to an error directly in the IDE.

Asynchronous view state

One very common pattern is that parts of the view state is asynchronously loaded based on state from the URL. In our example, this could be a browser callable service that loads data for the employee view based on an employee id and optionally also a filter. We want to automatically load updated data when the value of either of the view state parameter changes and we want to take the loading state into account when rendering the view. Automatic reload is the core feature of signal effects whereas the loading state can be handled using some TypeScript magic.

const data = useLoader(() => EmployeeService.getDetails(employeeId.value, filter.value));

return <>
  <TextField value={filter.value} onValueChanged={(e) => filter.value = e.detail.value}  />
  { data.loaded ? renderData(data.value) : "Loading ..." }
</>

Note that the type of data is defined so that the value property is only available when the loaded flag is set to true. In this way, TypeScript will help you remember to take the loading (and error) state into account.

A loader defined in this way can use any signal and is not limited to the view state signals defined in this RFC.

Prototype

I’ve built a prototype of these concepts at GitHub - Legioth/view-state: Proof of concept with type-safe view state management based on signals for Hilla. In addition to what’s described here, the prototype also handles view hierarchies so that a child view has access to the view state of any parent layout while the parent layout can have optional access to the view state of all its potential children. The prototype doesn’t automatically generate code based on the view configuration but there’s instead a “fake generated” file that is written by hand to contain the code that should be generated.

Would this work?

This is just an early prototype of a feature that we might add to Hilla at some point in the future. Before we even consider getting there, I would like to hear your thoughts on this idea. In particular, there are these essential questions:

  • Would the features described here be enough for your use cases or is there something fundamental that is missing?
  • Do you see any special edge cases that should be taken into account?
  • Do you have ideas for how something similar could be done in a better way?
4 Likes

Thank you for sharing this RFC @Leif! These are some very nice and type-safe convenience improvements :+1:

I’ve realized that there’s some additional types of view state that might be beneficial to manage as signals. Together with the ones already described, the whole set to consider could look like this:

  • Path segments as described above.
  • Query parameters as described above.
  • In-memory instance-scoped values. These could just be useSignal in the view except in the case when you want to easily share something between parent and child views.
  • In-memory module-scoped values that should remain when navigating away from a view and back again but should be reset if reloading the page or opening the app in a new browser tab. These could be signal instances in module-level variables but sharing between parent and child is again easier as part of the view state.
  • Configuration options stored in localStorage or sessionStorage (or IndexedDB if there’s some technical reason for that, or cookies if the application developer wants to automatically send the value to the server).
  • Configuration options stored in the application’s database for the currently logged-in user.

Will this have any value / implications for Flow?

This is probably unfair, but I worry whenever you do Hilla stuff (like the recent DevTools AI push)
Hilla might be great, but I can’t imagine anyone picking that if they only want a client-side framework.
To me, Hilla has no value except as support for Flow, so I worry that your focus is in the wrong place

This concept is based on generating TS types and some support code based the detected router configuration. We do not currently have any established solution for generating Java types in a similar way which means that the same concept couldn’t be used as-is for Flow.

We might still want to provide helpers for view state management in Flow once we have basic signal support in place but that would be a separate design.