Skip to main content

Handling Local Time Zones with Phoenix

· By Joe Bellus · 5 min read

There is little more maddening than dealing with time zones. One good thing about client side web development is – it happens on the client. On the client you have easy access to the user's locale information. So, you can store all your time data in UTC like a good citizen of the world and simply render it to local time client-side.

When rendering via server side code, this becomes a problem. Luckily, there is a rather sound solution for dealing with this problem with LiveViews. LiveViews are built with server side code, but they are rendering through client side interaction.

There are two main areas where time zones become important:

  1. Rendering dates & times to the user – Throughout an application there are, often, many instances where date/times need to be displayed. Generally, its a better experience to render these in the user's local time.
  2. Acting on Date/Time information server side – There are times it is helpful to have the user's locale when doing server side processing --collecting records from a database for a given time frame, accepting date/times from the user without an explicit time zone declaration, and more.

This article demonstrates methods of solving both these problems: Easily displaying date/times in the user's local time zone and having their time zone server side. This is all done without the need for the user to fill out a time zone in some kind of profile and retain it in a database. Forcing the user to inform you of their time zone isn't the greatest user experience.

ℹ️
This will assume a brand new Phoenix application generated with phx.new. This should realistically work for any Phoenix application and requires nothing that isn't standard in the framework.

Rendering Dates & Times for The User

For the first half of the problem, displaying UTC time to the user in their local time, we need two steps. First, we create a component that makes it easy to pass in DateTime values. Second, we build a client side hook that will convert that time to local time client side.

Creating a Component

This is a simple LiveView component that calls the JavaScript hook. It takes a DateTime value as the value attribute, a format option, and additional CSS classes if needed. Add this function to the core_components.ex file, or any other component module you have.

LiveComponents require an ID, here we are generating a random one, so we don't have to enter an ID every time we use it. The opacity-0 class is here to prevent the content from showing until the conversion is complete.

attr :value, :any, required: true
attr :format, :string, default: "datetime"
attr :class, :any, default: ""

def local_time(assigns) do
  assigns = assign_new(assigns, :id, &random_id/0)

  ~H"""
  <time id={@id} 
    phx-hook="LocalTimeHook" 
    class={[@class, "opacity-0"]} 
    data-format={@format}>
    {@value}
  </time>
  """
end

lib/app_name_web/components/core_components.ex

The random_id/0 function below simply generates a random ten character string:

defp random_id(), do: 
  :crypto.strong_rand_bytes(10) 
  |> Base.url_encode64() 
  |> binary_part(0, 10)

lib/app_name_web/components/core_components.ex

Creating a Hook

This is the JavaScript Hook called by the component. It reads the value from the element content and replaces it with the formatted date. There are two formats handled here: long-date and datetime, this could be expanded to any formats that are useful. To keep things organized its useful to place hooks in a hooks folder inside assets/js.

const LocalTimeHook = {
  mounted() {
    this.updated();
  },
  updated() {
    const dtString = this.el.textContent.trim();
    const dt = new Date(Date.parse(dtString));
    const format = this.el.dataset.format;

    let options = {};

    switch (format) {
      case "long-date":
        this.el.textContent = dt.toLocaleDateString("en-US", {
          weekday: "long",
          year: "numeric",
          month: "long",
          day: "numeric",
        });
        break;
      case "datetime":
        let date = dt.toLocaleDateString("en-US", {
          year: "numeric",
          month: "short",
          day: "numeric",
        });
        let datetime = dt.toLocaleTimeString("en-US");
        this.el.textContent = `${date} ${datetime}`;
        break;
    }
    this.el.classList.remove("opacity-0");
  },
};

export default LocalTimeHook;

assets/js/hooks/local_time.js

Now import the new hook file into app.js:

import LocalTimeHook from "./hooks/local_time";

assets/js/app.js

Add the hook into the LiveSocket constructor, so it can be used:

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {
    LocalTimeHook,
  },
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
});

assets/js/app.js

That's it!. Now you can use the component on any LiveView page:

Your current local time is 
<.local_time value={DateTime.utc_now()} />

lib/app_name_web/live/show_time_live.ex

Retrieving the User's Time Zone

Correctly rendering times to the user is only half the battle, the other half is knowing the user's time zone server side. We will handle sending the time zone details to LiveView pages.

When a LiveView is first loaded a websocket connection is established. Arbitrary data can be sent along this initial connection/handshake and retrieved server side. This is the mechanism we will use to hand off the time zone and offset.

Modifying the LiveSocket

In app.js we can pass the time zone information into the live socket constructor. The important part is the params key. Here we can pass anything we want alongside the security token.

let timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let time_offset = new Date().getTimezoneOffset();

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {
    LocalTimeHook,
  },
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken, timezone, time_offset },
});

assets/js/app.js

Now we can aquire the timezone within a live view by calling Phoenix.LiveView.get_connected_params/1:

timezone = get_connect_params(socket)["timezone"]
time_offset = get_connect_params(socket)["time_offset"]

This is cumbersome to do anytime we need it. Instead, we can create a Plug that will provide us these values automatically. Create a new file such as plugs/locale_plug.ex in the app_web path.

defmodule MyAppNameWeb.LocalePlug do
  use Phoenix.LiveView

  def on_mount(:set_locale, _params, _session, socket) do
    timezone = get_connect_params(socket)["timezone"]
    time_offset = get_connect_params(socket)["time_offset"]
    {:cont, assign(socket, user_timezone: timezone, user_time_offset: time_offset)}
  end
end

lib/app_name_web/plugs/locale_plug.ex

Now we can use this plug in router.ex anywhere we want this data automatically populated, by adding it to on_mount in the live_session.

live_session :root,
  on_mount: [
    {MyAppWeb.LocalePlug, :set_locale}
  ] do
  scope "/" do
    live "/", LocalTime
  end
end

lib/my_app_web/router.ex

Both timezone and time_offset are now available in the socket assigns of any liveview in that session. You could use it in a view directly with @timezone, or access it from the LiveView code with socket.assigns.timezone.

Going Further

This gives a way to both format times to the user and act upon local time server side, without the need to bother the user to enter their timezone.

Along side this, it could be useful to transmit other locale information as well. The user's raw locale information for number formatting, currency, and language could also be transmitted using the same mechanisms.

Source code

The code for this repository can be found at:

5sigma-blog/local_time_with_phoenix
Local time example for Phoenix. Contribute to 5sigma-blog/local_time_with_phoenix development by creating an account on GitHub.
Updated on Jun 12, 2025