How we started using Redux-Form in our Rails apps
A month ago we published the article Dry-rb and Trailblazer Reform, where our colleague Dmitry Voronov explained that the standard Rails way approach had ceased to meet company requirements and that Dry-rb/Reform stack had been accepted into JetRockets.
In this article I’ll try to describe the changes we’ve seen in front-end development, tell you which tool we’ve chosen and show you how to work with it via examples.
Over time, client forms become more complex. There are the more dependent elements, the parts with asynchronous data upload added, and there are the nested fields with many non-trivial dependencies. It appears that an external view of the form and the set of necessary data strongly depends on its elements. It becomes more difficult to support such forms, especially when the code responsible for them has been changed by 2 or 3 developers over a period of several years. In such conditions the probability of errors certainly increases.
Thus, if and when the logic gets more complicated on the server, we’ve decided to write our client code in a different way. There were a lot of variants, but since we had been planning to use React in production for a long time, then our choice fell on it. We decided to use Redux for data storage and Redux-Form for work with forms.
We use gems Webpacker and React-rails to integrate React into Ruby on Rails. That’s why we won’t describe this process but will focus on examples.
Form render
We’ll start with the rendering of the required component in index.html.slim:
= react_component("settings/plans", react_container_props)
React_container_props
is a simple helper_method in the controller that returns an object consisting of the data which will be needed in the react.
def react_container_props
super.reverse_merge({
refs: {
optionsForApplicationTypes: [...],
optionsForEmployeeRoles: [...],
availablePlanChannelTypes: [...],
availablePlanChannelCalculationMethods: [...]
}
}).as_json.as_camelize
end
Index file React component settings/plans looks like this:
import React from 'react';
import { Provider } from 'react-redux';
import { syncHistoryWithStore } from 'react-router-redux'
import { Router, Route, IndexRoute, browserHistory } from 'react-router'
import configureStore from './store';
...
...
function PlansIndex(props) {
const { refs } = props;
const store = configureStore(refs);
const history = syncHistoryWithStore(browserHistory, store);
return (
<Provider store={store}>
<Router history={history}>
<Route path="/settings/plans" component={PlansLayout}>
<Route path="new" component={(props) => (<PlanNew {...props} refs={refs}/>)} />
<Route path=":id/edit" component={(props) => (<PlanEdit {...props} refs={refs}/>)} />
</Route>
</Router>
</Provider>
)
}
export default PlansIndex;
Refs is the data we’ve transmitted from the controller using helper_method
react_container_props
. We use it as initialState
for our store.
PlanNew
and PlanEdit
functions are just the wrappers for PlansFormContainer
:
import React from "react";
import PlansFormContainer from "../shared/form/container";
function PlanNew(props) {
return (
<div className="wrapper-fluid">
<PlansFormContainer {...props} />
</div>
)
}
export default PlanNew;
It is a «smart» component that prepares parameters for our form:
// ../shared/form/container
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import {
...
} from "./selectors";
import * as actions from "components/settings/plans/redux/actions/planEditActions";
import PlansForm from "./components/Form";
const mapStateToProps = (state, props) => ({
optionsForApplicationTypes: state.plans.refs.optionsForApplicationTypes,
optionsForEmployeeRoles: [...],
availableProducts: [...],
optionsForPlanChannelTypes: [...],
...,
optionsForEmployees: optionsForEmployeesFromCollection(
state.plans.refs.availableEmployees
),
selectedEmployee: selectedEmployeeSelector(state)
});
const mapDispatchToProps = dispatch => ({
actions: bindActionCreators(actions, dispatch)
});
export default connect(mapStateToProps, mapDispatchToProps)(PlansForm);
A part of data, for example optionsForApplicationTypes
, is objects from the store as is. Other selectors, for example selectedEmployee
, are a function-selector that chooses and prepares required data forms. It looks like this:
// selectors.js
import { createSelector } from "reselect";
...
export const selectedEmployeeSelector = createSelector(
planFormSelector,
refsSelector,
(planForm, refs) => {
const employeeId = parseInt(get(planForm, "values.plan.employee_id"));
const employees = refs.availableEmployees;
return find(employees, { id: employeeId });
}
);
...
Here we use a framework Reselect.
Let’s move on to the form. Toggle Redux-Form as it is written in the documentation of the component.
// Form.jsx
import React, { Component } from "react";
...
import submit from "./submit";
...
class PlanForm extends Component {
constructor(props) {
super(props);
}
// ...
render() {
return (
<Form
onSubmit={handleSubmit(submit)}
>
{/* form body */}
</Form>
);
}
}
PlanForm = reduxForm({
form: "planForm"
})(PlanForm);
const selector = formValueSelector("planForm");
PlanForm = connect(state => {
const id = selector(state, "plan[id]");
const initialData = state.plans.initialData;
...
return ({
initialValues: initialData,
plan: {
id,
...
}
...
})
})(PlanForm);
export default PlanForm;
We specify in connect, the values of which form fields we want to select, and place them in the props of our form. We also state where the form should take the initial data from: state.plans.initialData
.
We use one form to create and edit the object. Thus we should check where we are at the moment in method componentDidMount
:
componentDidMount() {
const { fetchPlanRequest } = this.props.actions;
const planId = this.props.params.id;
if (planId) {
fetchPlanRequest(planId);
}
}
And if there's id in params, then we are on the edit page and it’s necessary to take the data of the current object from the server.
// components/settings/plans/redux/actions/planEditActions.js
export function fetchPlanRequest(id, params = {}) {
return (dispatch, getState) => {
axios
.get(
id
? Routes.edit_settings_plan_path(id, { ...params, format: "json" })
: Routes.new_settings_plan_path({ ...params, format: "json" }),
{
withCredentials: true
}
)
.then(res => {
...
dispatch(fetchPlanData(res.data));
...
});
};
}
Action in the controller looks like this (new looks absolutely the same way except for the authorize method and form name in GlobalContainer):
def edit
authorize resource_plan, :update?
form = GlobalContainer['plan.forms.update_plan_form_class'].new(resource_plan)
form.prepopulate!
respond_to do |format|
format.json {
render json: {
plan: Plan::EditPlanRepresenter.new.(form.sync)
}
}
format.html {
render :index
}
end
end
Representer code:
class Plan::EditPlanRepresenter < BaseRepresenter
def call(plan)
{
id: plan.id,
application_type: plan.application_type,
start_date: plan.start_date,
end_date: plan.end_date,
product_id: plan.product_id,
employee_role: plan.employee_role,
employee_id: plan.employee.try(:id),
channels: render_channels(plan.channels)
}
end
private
def render_channels(channels)
channels.map do |channel|
{
...channels fields...
}
end
end
def render_tiers(tiers)
tiers.map do |tier|
{
...tiers fields...
}
end
end
end
If the data is successfully loaded, reducer places it into state.plans.initialData
, where the form will take initial values from.
Visual representation
For representation including forms we used SemanticUI, to be more exact, its react version. This set of components was enough for us, since the app was an internal system and there were no requirements for design.
We couldn’t find ready solutions for the integration of SemanticUI and Redux-Form.
But there is enough information on how to work with third party components in the documentation of the latter.
It’s enough to wrap SemanticUI components into the component Field from Redux-Form. Below there is an example of wrapper implementation for the drop-down list; propTypes and defaultProps are omitted:
// From.jsx
import SelectUI from "components/shared/inputs/selectUI";
...
<Field
id="plan_employee_role"
required={true}
name="plan[employee_role]"
label={i18n.activerecord.attributes.plan.employee_role}
options={optionsForEmployeeRoles}
action={changeEmployeeRole}
component={SelectUI}
includeBlank={true}
/>
...
// components/shared/inputs/selectUI.js
import { Form, Select } from "semantic-ui-react";
...
const propTypes = {
...
};
const defaultProps = {
...
};
const SelectUI = props => {
return (
<Form.Field required={required}>
{label && <label>{label}</label>}
<Select
fluid
label={label}
selectOnBlur={false}
closeOnBlur
options={optionsForSelect(options)}
placeholder={placeholder}
required={required}
value={input.value}
error={error && error.length > 0}
onChange={(e, data) => {
input.onChange(data.value);
if (action) {
action(e, data);
} else {
return true;
}
}}
/>
{error && <span className="errored">{error}</span>}
</Form.Field>
);
};
SelectUI.propTypes = propTypes;
SelectUI.defaultProps = defaultProps;
export default SelectUI;
Validation and form submission
The opportunity to validate data in several ways is implemented in Redux-Form. We’ve decided to use Submit Validation. We leave only require validation for the client, and the rest of the checks are on the server side.
How does it work?
We specify function submit in the form component, that returns promise as props.
<Form onSubmit={handleSubmit(submit)}
Submit
function code
// submit.js
import { SubmissionError } from "redux-form";
import axios from "axios";
import _ from "lodash";
import { browserHistory } from "react-router";
let Routes = require("utils/routes");
const prepareErrors = errors => {
// conver the object with errors
...
};
const submit = (values, dispatch, props) => {
const url = values.plan.id
? Routes.settings_plan_path(values.plan.id, { format: "json" })
: Routes.settings_plans_path({ format: "json" });
const method = values.plan.id ? "patch" : "post";
return axios
.request({
url: url,
method: method,
data: values,
withCredentials: true,
xsrfHeaderName: "X-CSRF-Token"
})
.then(res => {
// Data was saved. Make a redirect...
const location = res.headers.location;
browserHistory.push(location);
})
.catch(error => {
// Error was catched
const preparedErrors = prepareErrors(error.response.data.payload.errors);
if (error.response.data.payload.errors) {
throw new SubmissionError({
plan: preparedErrors
});
}
});
};
export default submit;
By clicking on the submit button a post and patch request to the server occurs. If the data is successfully saved, you can redirect the user by location, which comes from the server. If there’s an error, you should take this incoming object errors and transmit its functions SubmissionError
.
Before you transmit the errors of SubmissionError
function, you should transform them. By default the object in the following form came from the server:
{
field_1: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
field_2: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
field_3: {
field_3_1: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
field_3_2: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
...
}
}
PrepareErrors has just replaced array bugs with the first of them:
{
field_1: 'Error 1 text',
field_2: 'Error 1 text',
field_3: {
field_3_1: 'Error 1 text',
field_3_2: 'Error 1 text',
...
}
}
Redux-Form exactly in this form expects to get object errors.
An example of create method in the controller:
def create
authorize resource_plan, :create?
command = GlobalContainer['plan.services.create_plan_command'] #Plan::CreatePlan.new
respond_to do |format|
format.json {
command.call(resource_plan, params[:plan]) do |m|
m.success do |plan|
flash[:notice] = t('messages.created', resource_name: Plan.model_name.human)
render json: { id: plan.id}, status: :ok, location: settings_plan_path(plan)
end
m.failure do |form|
render json: {
status: :failure,
payload: { errors: form.react_errors_hash }
}, status: 422
end
end
}
end
end
React_errors_hash
form method forms object errors.
Conclusion
Comparing the “jQuery” forms that were written several years ago and what we got eventually, I was very upset, but this is only because of the fact that I hadn’t used any of this before. Code has become more readable and predictable. Adding of new fields into the form is no longer a pain, even if it changes the behavior logic of the interface. Each part of the form code minds its own business: form class is responsible for representation and conditional rendering of the elements, actions for receiving data and reducers for their changes in the component store.
Redux-Form is a really powerful tool for working with forms. And I’m very glad that it’s being developed and supported by Erik Rasmussen, not only as a component for React, but now also as a zero dependency Final Form framework.