User-Story Relationship Issue on Create: (KeyError) key :email not found in: #Ecto.Association.NotLoaded

Phoenix: User-Story Relationship Issue on Create

Hello Elixir community,

I’m new to Elixir and Phoenix, and I’m currently building a simple blogging platform as a learning project. The application has a one-to-many relationship between users and stories, both created using Phoenix generators.

I’m trying to display the user’s email on the story_live index page, but I’m encountering an issue when creating a new story. Here’s the relevant code:

# In lib/my_app/stories/story.ex
schema "stories" do
   field :title, :string
   field :body, :string
   belongs_to :user, Blog.Accounts.User

   timestamps(type: :utc_datetime)
end

# In web/live/story_live/index.ex
@impl true
def mount(_params, _session, socket) do
  {:ok, stream(socket, :stories, Stories.list_stories())}
end

# In lib/my_app/stories.ex
def list_stories do
  Story
  |> preload(:user)
  |> Repo.all()
end

# In web/live/story_live/index.html.heex
<.table
  id="stories"
  rows={@streams.stories}
  row_click={fn {_id, story} -> JS.navigate(~p"/stories/#{story}") end}
>
  <:col :let={{_id, story}} label="Title"><%= story.title %></:col>
  <:col :let={{_id, story}} label="Body"><%= story.body %></:col>
  <:col :let={{_id, story}} label="User Email"><%= story.user.email %></:col>
  <!-- ... action columns ... -->
</.table>

<.modal :if={@live_action in [:new, :edit]} id="story-modal" show on_cancel={JS.patch(~p"/stories")}>
  <.live_component
    module={BlogWeb.StoryLive.FormComponent}
    id={@story.id || :new}
    title={@page_title}
    action={@live_action}
    story={@story}
    user_id={@current_user.id}
    patch={~p"/stories"}
  />
</.modal>

This setup works fine for displaying existing stories and their associated user emails. However, when I create a new story, the Elixir process crashes with the following error:

[error] GenServer #PID<0.1522.0> terminating
** (KeyError) key :email not found in: #Ecto.Association.NotLoaded<association :user is not loaded>
    (blog 0.1.0) lib/blog_web/live/story_live/index.html.heex:17: anonymous fn/3 in BlogWeb.StoryLive.Index.render/1
    (phoenix_live_view 0.20.17) lib/phoenix_live_view/diff.ex:391: Phoenix.LiveView.Diff.traverse/7
    ...

The error suggests that the :user association is not loaded for the newly created story. This is puzzling because both the index and create actions use the same list_stories context function, which preloads the :user details.

I’m particularly confused by these aspects:

  1. Why does the error occur only for newly created stories?
  2. Why doesn’t the preload(:user) in list_stories function seem to be working for new stories?
  3. Is there a difference in how Phoenix handles newly created records vs. existing ones in LiveView?

I would greatly appreciate any insights into this behavior. I’m eager to understand and learn from this issue to improve my grasp of Phoenix and Elixir.

Thank you in advance for your time and expertise!

You haven’t shown your create event handler. You need to show us the contents of Blogweb.StoryLive.FormComponent and the context method that is called from that form component.

Thank’s for pointing that out! Here’s the Blogweb.StoryLive.FormComponent code:

defmodule BlogWeb.StoryLive.FormComponent do
  use BlogWeb, :live_component
  
  alias Blog.Stories

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.header>
        <%= @title %>
        <:subtitle>Use this form to manage story records in your database.</:subtitle>
      </.header>

      <.simple_form
        for={@form}
        id="story-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:body]} type="textarea" label="Body" />
        <:actions>
          <.button phx-disable-with="Saving...">Save Story</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  @impl true
  def update(%{story: story} = assigns, socket) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign_new(:form, fn ->
       to_form(Stories.change_story(story))
     end)}
  end

  @impl true
  def handle_event("validate", %{"story" => story_params}, socket) do
    changeset = Stories.change_story(socket.assigns.story, story_params)
    {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
  end

  def handle_event("save", %{"story" => story_params}, socket) do
    story_params = Map.put(story_params, "user_id", socket.assigns[:user_id])
    save_story(socket, socket.assigns.action, story_params)
  end

  defp save_story(socket, :edit, story_params) do
    case Stories.update_story(socket.assigns.story, story_params) do
      {:ok, story} ->
        notify_parent({:saved, story})

        {:noreply,
         socket
         |> put_flash(:info, "Story updated successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  defp save_story(socket, :new, story_params) do
    case Stories.create_story(story_params) do
      {:ok, story} ->
        notify_parent({:saved, story})

        {:noreply,
         socket
         |> put_flash(:info, "Story created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

Does the story returned by Stories.create_story/1 have the user preloaded? If not and the parent liveview that gets notified of it then directly streams it, that would explain this error.

1 Like

Edit 2:

I just realised you may be refering to existing records as the mounted records, and the preload may be failing on the updated stream when you are creating new records.

The below is what I use for updating messages in a stream.

Info() is specific to my app. Its something I use to share handle_info over multiple index routes, so treat it like handle_info.

Pass the message to a handle_info() then use the message id to get a preloaded version of the message, then insert it into the stream. Might be able to run repo.preload on the message in the params, can’t remember why I didn’t.

  def create_message(params, current_user, room) do
    if length(message_limit_reached(current_user, room)) >= 10 do
      {:ok, :rate_limit}
    else
      previous_message = get_previous_message(room)
      merge_flag       = if previous_message && previous_message.user_id === current_user.id, do: true, else: false

      Multi.new()
        |> Multi.insert(:message, %Message{user_id: current_user.id, room_id: room.id, merge: merge_flag} 
        |> Message.changeset(params))
        |> Repo.transaction()
        |> notify(room.id, :message_created)
      {:ok, :new_message}
    end

  def info(:message_created, %{message: message}, socket) do
    new_message = ecto_get!(Message, message.id, [:user])
    {:noreply, stream(socket, :messages, [new_message], at: 0)}
  end

  def ecto_get!(module, id, preload \\ []) do
    Repo.get!(module, id)
    |> Repo.preload(preload)
  end

Edit: The answer below is likely wrong, but still may be useful.

When you say existing records work, do you mean records you’ve seeded to the database?

Have you come accross IO.Inspect yet?

In your mount or heex add: IO.inspect(Stories.list_stories())

IO.inspect(Stories.list_stories())

or in the heex:

<%= IO.inspect(@streams.stories) %>

The above will output the inspect results to terminal and will consist of all returned stories by the list_stories function, and for every story returned, near the bottom of the Struct you will see all available preloads as lists.

To narrow down your issue the you can use inspect to see whether:

  • No story preloads exist
  • All story preloads exist
  • Existing story preloads exist but new stories do not.

As an example you will get something like the below in the terminal from the inspect.

1 - If your preload isn’t set up, but an association exists it will return not loaded.
2 - If the preload is set up but no record exists then you will get nil or depending on whether its belongs_to or has_many.
3 - If the preload has been set up and the record exists, the struct will be returned

1 - children: #Ecto.Association.NotLoaded<association :children is not loaded>,
2 - comments: nil
3 - user: #Phxie.Schema.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: 1,
  etc...

Sorry this doesn’t answer the question, but IO.Inpsect() will always help you to narrow down the cause of the issue.

Hope this helps, and good luck.

1 Like

In your Stories.create_story/1 you almost certainly are not preloading the user so it is missing on the created record.

1 Like

Thank you all for your help in diagnosing the issue! I really appreciate the time and expertise you’ve shared.

As you suggested, the problem was that my create_story function wasn’t preloading the :user details. I’ve updated the code as recommended, and it now works perfectly.

Here’s the updated create_story function:

def create_story(attrs \\ %{}) do
  %Story{}
  |> Story.changeset(attrs)
  |> Repo.insert()
  |> case do
    {:ok, story} -> {:ok, Repo.preload(story, :user)}
    {:error, changes} -> {:error, changes}
  end
end

Thank you all again for your help!