Sharing a not-much-elegate way to implement i18n in Phoenix LiveView (can you help improve it?)

Hi everyone,

I’ve been working on implementing real-time i18n in my own Phoenix LiveView application, specifically on the registration page. The goal was to allow users to change the language of the form instantly.

After complete this feature, I had browse forum to see community’s solution and I found Routex - build powerful Phoenix routes: localize, customize, and innovate finally. It’s a pity to erase the experience so I wanna write my solution and have a discuss related how to make it better(the code seems to be a little bit ugly, frankly speaking).

Repo: GES233/EchoesUnderBlossoms: Some echoes should not be forgotten. (document seems to be a bit chunibyo cause I refactored it by Gemini several times.)

The Initial Goal & Problem

On my registration LiveView (HanaShirabeWeb.MemberLive.Registration), I have a language selector. When a user changes the language, the UI text (labels, buttons) should update instantly.

At the beginning, I write a function attampt to handle it simply, Gettext.put_locale(some_target_locale).

I forgot what the problem was, but the nav bar remained.

First attempt

To my shame, I knew absolutely nothing about Phoenix and LiveView before this.

I implement a cookie to store the locale state before(a plug called SetLocale), so I tried to use it to persist locale state at client side.

This is how it determine languages:

defp fetch_locale_from_sources(conn) do
  locale_from_user =
    if !is_nil(conn.assigns.current_scope),
      do: conn.assigns.current_scope.member.prefer_locale,
      else: nil

  [
    conn.params["locale"],
    locale_from_user,
    conn.req_cookies[@locale_cookie],
    get_req_header(conn, "accept-language") |> parse_accept_language()
  ]
end

But when I add /?locale=en/ja/... request to refresh the page, the language changes, but with full of rough(I don’t know hot to describe it in English accurately) and all data in form disappear.

I tried to convince myself that since the user required changing the language settings in form, the data wasn’t necessary to store.

I don’t know if I succeeded, but it seems like other data isn’t being saved.

But I’m actually quite against stuffing all sorts of data into the user-visible params, because many websites stuff links with all sorts of data that could potentially track users(such as Bilibili[1], Douyin, RedNote, etc.), and what’s even more disgusting is that many people spread these links everywhere with several parameters that have no meaning for sharing.

So I have HIGH requirements for the simplicity of website links.

I don’t remember how many AI programs I’ve tried(at least GPT/Grok/Gimini/Qwen/Deepseek), so I’ll just go straight to solution.

ColocatedHook + Controller + Delay Reflash

1. Phoenix LiveView → Client(ColocatedHook)

def handle_event(
      "locale_changed",
      %{
        "_target" => ["registration_form", "prefer_locale"],
        "registration_form" => %{"prefer_locale" => locale}
      },
      socket
    ) do
  Gettext.put_locale(HanaShirabeWeb.Gettext, locale)

  # The test code for this function only needs to account for cookie updates.
  socket = push_event(socket, "set_locale_cookie", %{locale: locale})

  {:noreply, socket}
  end

2. Client → Controller

in <script :type={Phoenix.LiveView.ColocatedHook} name=".LocaleFormInput">:

  export default {
    mounted() {
      const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");

      this.handleEvent("set_locale_cookie", ({ locale }) => {
        fetch(`/set-locale/${locale}`, {
          method: "POST",
          headers: {
            "x-csrf-token": csrfToken
          }
        }).then(response => {
          if (response.ok) {
            this.pushEvent("locale_cookie_updated", {locale: locale});
        }}).catch(error => {
          console.error(`Failed to set locale cookie to '${locale}':`, error)
        });
      });
    }
  }

And in server side:

defmodule HanaShirabeWeb.LocaleController do
  @moduledoc """
  This is actually a plugin designed to allow the page language to be updated immediately
  when the language is changed in the registration form.
  """
  use HanaShirabeWeb, :controller

  # It needs to be determined that cookies have a lower priority than `?locale=(...)` and user settings.
  def update(conn, %{"locale" => locale}) do
    # I extract the inject-cookie-phase into a function
    conn |> HanaShirabeWeb.SetLocale.persist(locale) |> send_resp(204, "")
  end
end

with router: post "/set-locale/:locale", LocaleController, :update

3. Refresh

# Because of the properties of LiveView's underlying socket and the "global" action of changing the language
# this means it cannot be solved using Phoenix.LiveView
# so this very inelegant method has to be used.
# すみません
def handle_event("locale_cookie_updated", %{"locale" => locale}, socket) do
  # for that flash message with `Locale Updated!`
  Gettext.put_locale(locale)

  {:noreply,
   socket
   |> put_flash(:info, gettext("Locale updated!"))
   |> redirect(to: ~p"/sign_up", replace: true)}
end

Display

There’s no gif demostration because of its size.

Besides sharing this rather bumpy experience to vent, I’m also curious if there’s a more elegant way to do it?

Also, I know that the audience of my Repo project is not very relevant with here, but if you want to discuss it, you can do so here.


  1. 添加去除跳转时网址参数(?大概)功能的 · Issue #263 · the1812/Bilibili-Evolved ↩︎

1 Like

Changing locale will not reactively change content, So most people will just push_navigate/2 and embed the local in url parameter. But then as you discovered, everything in the socket assigns are lost.

If for some reason you want to keep all the socket assigns, but update the content with new translation, you can do this:

  • keep locale in the socket assigns and pass it down, in addition to Gettext.put_locale/1
  • Write your own gettext wrappers with the passed down locale as the additional function parameter. You don’t need to do anything with the passed down locale, prefix it with _ in your wrapper

Then the changed locale will trigger re rendering. It is a lot of work though.

2 Likes

There’s really two things here. There’s updating the session (cookie) and there’s updating LV for a new gettext locale.

For the former, there’s simply no good solution when using cookies. Cookies can only be updated on http requests, so there’s no way around making one of those. With a server side session implementation you could do the write on the server without additional requests.

For the latter the problem is that gettext was implemented way before LV and/or change tracking became a thing. So gettext uses the process dictionary to store the current locale for gettext functions to pick it up. Changes in the process dictionary completely go around assigns and the change tracking of LV however. If you want the LV to rerender when the locale changes – without navigation being involved – you’d need to put the locale in assigns and make all places depending on the locale use Gettext.with_locale explicitly.

2 Likes

Version 1.3 was released yesterday. Now with Igniter install support and a few other Quality of Life improvements.

Also a new demo site is coming up in a few days.

Would love to receive your feedback :slight_smile:

1 Like

To extend @LostKobrakai’s advice since you discovered Routex:

Routex gets the locale information from a configurable variety of sources. This allows navigation with the proper locale every time. When fully loading a page using navigate, the whole page is re-rendered so all is good by default. When using patch you might need some cheats.

 <%!-- Because we use `patch`, we need a workaround to force LV to refresh this element
        entirely as it contains Gettext translated strings in template (so their changes are not tracked) --%>

<%= Gettext.with_locale(@runtime.language, fn -> %>
  <h1>Localization</h1>
  <p>
    Routex' Localization extensions are highly customizable. This example page does not even scratch
    the surface of what is possible. Here are a few notable features making them unique:
  </p>
<% end %>

Or to force update links on navigation when using ‘patch’:

  # @url in the navigate links forces them to update when switching region
  # using '<.link patch={~p"/my/url"}`. It is adviced to use 'navigate' instead
  # of `patch` to simply full refresh the page when switching regions."
 <li><.link navigate={@url && ~p"/localize"} class="btn btn-ghost">Localize</.link></li>
 <li><.link navigate={@url && ~p"/verified"} class="btn btn-ghost">Verified Routes</.link></li>
 <li><.link navigate={@url && ~p"/cloak/products"} class="btn btn-ghost">Cloak</.link></li>