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?