Write props in snake_case in Rails, receive them in camelCase in your components — without breaking merge props, infinite scroll, deferred props, or partial reloads.
gem "inertia_rails-camelize_props"InertiaRails.configure do |config|
config.camelize_props = true
endOr per controller, like any other Inertia Rails option:
class MediaController < ApplicationController
inertia_config(camelize_props: true)
endThen keep writing Rails as usual:
render inertia: "Media/Index", props: {
media_list: InertiaRails.scroll(pagy) { ... },
}and refer to props by their camelCase names on the client:
<InfiniteScroll data="mediaList">Keys are converted:
props: { user_profile: { display_name: "John Doe" } }"props": { "userProfile": { "displayName": "John Doe" } }That happens at every level, once the props have been resolved, so whatever a block returned is converted like anything else. The keys of the flash are converted too.
A page also names props outside of props, in the metadata that tells the client what to merge, what to fetch later and what it already holds. Those names are converted to match, which is what keeps those features working — an infinite scroll over media_list goes out as:
{
"props": { "mediaList": [...] },
"scrollProps": { "mediaList": { "pageName": "cursor" } },
"mergeProps": ["mediaList"]
}The rest of that metadata is prependProps, deepMergeProps, matchPropsOn, deferredProps, onceProps, rescuedProps and sharedProps.
What is left alone names no prop: the group a deferred prop is batched under, the query parameter a scroll prop paginates with, and a once key you supplied yourself.
A frontend written in camelCase submits camelCase, so the parameters are renamed before the action runs:
# the request arrives as { mediaItem: { mediaId: …, createdAt: … } }
params.expect(media_item: [:media_id, :created_at])Only requests carrying X-Inertia are renamed.
The partial reload headers (X-Inertia-Partial-Data, X-Inertia-Partial-Except, X-Inertia-Reset, X-Inertia-Except-Once-Props) are matched against both the camelCase and the original name, so a frontend that still sends media_list keeps working.
- This gem patches private Inertia Rails internals, so an Inertia Rails upgrade may need a new release here.
- A name with a digit straight after an underscore does not survive the round trip:
address_line_1goes out asaddressLine1and comes back asaddress_line1, since case cannot mark a word boundary in front of a digit. Name attributes without an underscore in front of a digit. - Every key is a name to be converted, so do not build keys out of user input. A value that needs arbitrary keys can go as a JSON string, or as an array of objects carrying the key as a field.
- Remove any other case conversion: a key-renaming
prop_transformer, which still runs before this one, or inertia-caseshift on the client.
Inertia Rails already has prop_transformer, and its documentation suggests using it to camelize keys:
config.prop_transformer = ->(props:) { props.deep_transform_keys { |key| key.to_s.camelize(:lower) } }This silently breaks every feature that refers to a prop by name. Only the props hash is transformed, so the response disagrees with itself — here, an infinite scroll over media_list:
{
"props": { "mediaList": [] },
"scrollProps": { "media_list": { "pageName": "cursor" } },
"mergeProps": ["media_list"]
}The client looks up props["media_list"] to merge into, finds nothing, and replaces the accumulated list instead of appending to it.
Prop names appear in four places, and prop_transformer reaches only the first:
- the keys of the props hash
- the response metadata —
mergeProps,scrollProps,deferredProps,onceProps,matchPropsOn, … — which list prop names as values - partial reload request headers (
X-Inertia-Partial-Dataand friends), matched server-side - prop options such as
match_on:andappend:, which are strings on the prop object rather than keys of any hash
Moving the prop_transformer call to a different point does not help. A hash transform can only run after evaluation, since the keys inside a lazy prop's return value do not exist before it; but name matching must finish before evaluation, because skipping the evaluation of unrequested props is the whole point of partial reloads. Wherever the transform sits, the matching happens on the other side of it — and the match_on: strings are never keys of any hash, so no transform of one reaches them at all.
The shape is wrong. A Hash -> Hash transformer can only hand the adapter a transformed result, and a result is the one thing that cannot be reused: the protocol needs the mapping itself, a per-key function it can apply to a single name wherever that name turns up. No Hash -> Hash function, called at any point, can supply that.
Rails names things in snake_case and JavaScript in camelCase, and neither side should have to take up the other's convention to be understood. Something between them has to carry the difference, and the adapter is already there, writing the props and the protocol's names around them.
Inertia Rails offers prop_transformer, which the problem above rules out. What answers it is not a Hash -> Hash transformation but a naming rule: something the adapter holds and applies to one name at a time, wherever a name appears. The Elixir adapter already offers one, behind a camelize_props flag.
Pull request 267 tried before, reading the problem as merge props being left out rather than as the shape of the transformer, and added a second lambda for them. Inertia Rails means to deprecate prop_transformer rather than improve it, which is right: a Hash -> Hash function is the wrong shape for this. This gem adds camelize_props instead.
inertia-caseshift answers the same question on the client, in both directions, with one implementation serving every backend. This answers it on the server instead.
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt.
Bug reports and pull requests are welcome on GitHub at https://github.com/onk/inertia_rails-camelize_props.
The gem is available as open source under the terms of the MIT License.