This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.
Redux Channel Actions
Connecting to our socket and joining our channel in the base of our application doesn’t feel like the React Way™.
Instead, let’s define actions and reducers to connect to the socket and
join our "lists.public"
channel.
We’ll start by creating a connectSocket
action creator that
initializes our Socket
and connects it to our server. The
corresponding reducer for this action will save the socket
to our
state:
export function connectSocket(jwt) {
let socket = new Socket("/socket", {
params: {
token: jwt
}
});
socket.connect();
return { type: CONNECT_SOCKET, socket };
}
Next, let’s create a joinListsChannel
action creator that joins the
provided channel and dispatches addList
actions for each list returned
from the server:
socket
.channel(channel)
.join()
.receive("ok", (lists) => {
lists.forEach((list) => {
dispatch(addList(list));
});
dispatch(joinListsChannelSuccess(channel));
})
.receive("error", (error) => {
dispatch(joinListsChannelFailure(channel, error));
});
Now we’re connecting to our Phoenix channel in a much more Redux-friendly way. Plus, we have access to our socket within our application’s state!
web/static/js/actions/index.js
+import { Socket } from "deps/phoenix/web/static/js/phoenix" + export const SIGN_UP_REQUEST = "SIGN_UP_REQUEST"; ... +export const CONNECT_SOCKET = "CONNECT_SOCKET"; + +export const JOIN_LISTS_CHANNEL_REQUEST = "JOIN_LISTS_CHANNEL_REQUEST"; +export const JOIN_LISTS_CHANNEL_SUCCESS = "JOIN_LISTS_CHANNEL_SUCCESS"; +export const JOIN_LISTS_CHANNEL_FAILURE = "JOIN_LISTS_CHANNEL_FAILURE"; + export const ADD_LIST = "ADD_LIST"; ... +export function connectSocket(jwt) { + let socket = new Socket("/socket", { + params: { + token: jwt + } + }); + socket.connect(); + return { type: CONNECT_SOCKET, socket }; +} + +export function joinListsChannelRequest(channel) { + return { type: JOIN_LISTS_CHANNEL_REQUEST, channel }; +} + +export function joinListsChannelSuccess(channel) { + return { type: JOIN_LISTS_CHANNEL_SUCCESS, channel }; +} + +export function joinListsChannelFailure(channel, error) { + return { type: JOIN_LISTS_CHANNEL_FAILURE, channel, error }; +} + export function signUp(email, password, password_confirm) { ... } + +export function joinListsChannel(channel) { + return (dispatch, getState) => { + const { socket } = getState(); + + dispatch(joinListsChannelRequest()); + + socket + .channel(channel) + .join() + .receive("ok", (lists) => { + lists.forEach((list) => { + dispatch(addList(list)); + }); + dispatch(joinListsChannelSuccess(channel)); + }) + .receive("error", (error) => { + dispatch(joinListsChannelFailure(channel, error)); + }); + } +}
web/static/js/app.js
... import { - addList + connectSocket, + joinListsChannel } from "./actions"; -import socket from "./socket"; ... render(); -store.subscribe(render); -socket.connect(); -socket.channel("lists.public", {}) - .join() - .receive("ok", (res) => { - res.forEach((list) => { - store.dispatch(addList(list)); - }); - }) - .receive("error", (res) => { - console.log("error", res); - }); +store.dispatch(connectSocket(store.getState().jwt)); +store.dispatch(joinListsChannel("lists.public"));
web/static/js/reducers/index.js
... SIGN_IN_FAILURE, + CONNECT_SOCKET, ADD_LIST, ... const initialState = { + socket: undefined, user: user ? JSON.parse(user) : user, ... }); + case CONNECT_SOCKET: + return Object.assign({}, state, { socket: action.socket }); default:
List Page
Now that our lists are being populated in the sidebar of our application, we should pull in the components, layouts, and pages necessary to render them.
We’ll grab the ListPageContainer
, ListPage
, ListHeader
, and
TodoItem
React components from our original Meteor application and
move them into our Phoenix project.
The main changes we’ve made to these components is renaming
variables to match our Ecto models (incompleteCount
to
incomplete_count
, and userId
to user_id
), and refactoring how we
fetch lists.
Also, instead of using Meteor collections to fetch lists from Minimongo, we refactored our components to pull lists directly out of our application’s state:
let id = props.params.id;
let list = _.find(state.lists, list => list.id == id);
Now that we’ve added the necessary React components to our project, we can add the new ListPageContainer
to our router:
<Route path="lists/:id" component={ListPageContainer}/>
Clicking on a list in our sidebar shows the (empty) list in the main panel. Success!
package.json
"brunch": "^2.0.0", + "classnames": "^2.2.5", "clean-css-brunch": ">= 1.0 < 1.8",
web/static/js/components/ListHeader.jsx
+...
web/static/js/components/ListList.jsx
... > - {list.userId + {list.user_id ? <span className="icon-lock"></span> : null} - {list.incompleteCount - ? <span className="count-list">{list.incompleteCount}</span> + {list.incomplete_count + ? <span className="count-list">{list.incomplete_count}</span> : null}
web/static/js/components/TodoItem.jsx
+...
web/static/js/containers/ListPageContainer.jsx
+import ListPage from '../pages/ListPage.jsx'; +import { connect } from "react-redux"; +import _ from "lodash"; + +const ListPageContainer = connect( + (state, props) => { + let id = props.params.id; + let list = _.find(state.lists, list => list.id == id); + return { + loading: state.loading, + list: list, + listExists: !!list, + todos: [] + } + } +)(ListPage); + +export default ListPageContainer;
web/static/js/layouts/App.jsx
... if (this.props.params.id) { - const list = Lists.findOne(this.props.params.id); - if (list.userId) { - const publicList = Lists.findOne({ userId: { $exists: false } }); + const list = _.find(this.props.lists, list => list.id == this.props.params.id); + if (list.user_id) { + const publicList = _.find(this.props.list, list => !list.user_id); this.context.router.push(`/lists/${ publicList.id }`{:.language-javascript});
web/static/js/pages/ListPage.jsx
+...
web/static/js/routes.jsx
... import NotFoundPage from './pages/NotFoundPage.jsx'; +import ListPageContainer from './containers/ListPageContainer.jsx'; ... <Router history={browserHistory}> - <Route path="/" component={AppContainer}> + <Route path="/" component={AppContainer}> + <Route path="lists/:id" component={ListPageContainer}/> <Route path="signin" component={AuthPageSignIn}/> ... <Route path="*" component={NotFoundPage}/> - </Route> + </Route> </Router>
Preloading Todos
One of the cool features of Ecto is that we can write queries that automatically load, or “preload”, related objects.
For our Todos application, we can configure our List.public
query to
preload all associated Todo
objects:
from list in query,
where: is_nil(list.user_id),
preload: [:todos]
Now the todos
field on our List
will be a fully populated list of
all Todo
objects associated with that particular list.
To send those todos to the client, we need to tell Poison that we want the todos
field included in each serialized List
object:
@derive {Poison.Encoder, only: [
...
:todos
]}
We’ll also need to tell Poison how to serialize our Todo
documents:
@derive {Poison.Encoder, only: [
:id,
:text,
:checked
]}
Now on the client, we can tell our ListPageContainer
to pull our list
of todos
out of the list itself:
todos: _.get(list, "todos") || []
After fixing up a few minor variable name issues, our todos show up in each list page!
web/models/list.ex
... :incomplete_count, - :user_id + :user_id, + :todos ]} ... from list in query, - where: is_nil(list.user_id) + where: is_nil(list.user_id), + preload: [:todos] end
web/models/todo.ex
... + @derive {Poison.Encoder, only: [ + :id, + :text, + :checked + ]} + schema "todos" do
web/static/js/components/TodoItem.jsx
... updateText.call({ - todoId: this.props.todo._id, + todoId: this.props.todo.id, newText: value, ... onFocus() { - this.props.onEditingChange(this.props.todo._id, true); + this.props.onEditingChange(this.props.todo.id, true); } ... onBlur() { - this.props.onEditingChange(this.props.todo._id, false); + this.props.onEditingChange(this.props.todo.id, false); } ... setCheckedStatus.call({ - todoId: this.props.todo._id, + todoId: this.props.todo.id, newCheckedStatus: event.target.checked, ... deleteTodo() { - remove.call({ todoId: this.props.todo._id }, alert); + remove.call({ todoId: this.props.todo.id }, alert); }
web/static/js/containers/ListPageContainer.jsx
... listExists: !!list, - todos: [] + todos: _.get(list, "todos") || [] }
web/static/js/pages/ListPage.jsx
... todo={todo} - key={todo._id} - editing={todo._id === editingTodo} + key={todo.id} + editing={todo.id === editingTodo} onEditingChange={this.onEditingChange}
Final Thoughts
Although it was briefly glossed over in the commits, representing the WebSocket connection process and state in terms of Redux actions led to quite a bit of internal conflict.
In my mind, a highly stateful, constantly changing object like a socket or channel connection doesn’t neatly fit into the idea of “state”.
Our Redux event log could show that we successfully instantiated a socket connection, but that doesn’t mean that the socket referenced by our state is currently connected. It might have timed out, or disconnected for any other unknown reason.
Trying to capture and track this kind of transient, ephemeral thing in pure, immutable Redux state seems like a slippery and dangerous slope.
We don’t track things like network connectivity in Redux state, so why track WebSocket connectivity? That analogy isn’t exactly accurate, but I think it helps to describe some of my concerns.
Ultimately, I decided to keep the socket connection in the application state so it can be easily accessible to all components and actions.