Handling Data Dependencies

Nhost - Backend-as-a-Service with GraphQL for modern app development - Interview with Johan Eliasson

So far we have developed an application for keeping track of notes in localStorage. To get closer to Kanban, we need to model the concept of Lane. A Lane is something that should be able to contain many Notes within itself and track their order. One way to model this is simply to make a Lane point at Notes through an array of Note ids.

This relation could be reversed. A Note could point at a Lane using an id and maintain information about its position within a Lane. In this case, we are going to stick with the former design as that works well with re-ordering later on.

Defining Lanes#

As earlier, we can use the same idea of two components here. There will be a component for the higher level (i.e., Lanes) and for the lower level (i.e., Lane). The higher level component will deal with lane ordering. A Lane will render itself (i.e., name and Notes) and have basic manipulation operations.

Just as with Notes, we are going to need a set of actions. For now it is enough if we can just create new lanes so we can create a corresponding action for that as below:

app/actions/LaneActions.js

import alt from '../libs/alt';

export default alt.generateActions('create');

In addition, we are going to need a LaneStore and a method matching to create. The idea is pretty much the same as for NoteStore earlier. create will concatenate a new lane to the list of lanes. After that, the change will propagate to the listeners (i.e., FinalStore and components).

app/stores/LaneStore.js

import LaneActions from '../actions/LaneActions';

export default class LaneStore {
  constructor() {
    this.bindActions(LaneActions);

    this.lanes = [];
  }
  create(lane) {
    // If `notes` aren't provided for some reason,
    // default to an empty array.
    lane.notes = lane.notes || [];

    this.setState({
      lanes: this.lanes.concat(lane)
    });
  }
}

To connect LaneStore with our application, we need to connect it to it through setup:

app/components/Provider/setup.js

import storage from '../../libs/storage';
import persist from '../../libs/persist';
import NoteStore from '../../stores/NoteStore';
import LaneStore from '../../stores/LaneStore';
export default alt => { alt.addStore('NoteStore', NoteStore);
alt.addStore('LaneStore', LaneStore);
persist(alt, storage(localStorage), 'app'); }

We are also going to need a Lanes container to display our lanes:

app/components/Lanes.jsx

import React from 'react';
import Lane from './Lane';

export default ({lanes}) => (
  <div className="lanes">{lanes.map(lane =>
    <Lane className="lane" key={lane.id} lane={lane} />
  )}</div>
)

And finally we can add a little stub for Lane to make sure our application doesn't crash when we connect Lanes with it. A lot of the current App logic will move here eventually:

app/components/Lane.jsx

import React from 'react';

export default ({lane, ...props}) => (
  <div {...props}>{lane.name}</div>
)

Connecting Lanes with App#

Next, we need to make room for Lanes at App. We will simply replace Notes references with Lanes, set up lane actions, and store. This means a lot of the old code can disappear. Replace App with the following code:

app/components/App.jsx

import React from 'react';
import uuid from 'uuid';
import connect from '../libs/connect';
import Lanes from './Lanes';
import LaneActions from '../actions/LaneActions';

const App = ({LaneActions, lanes}) => {
  const addLane = () => {
    LaneActions.create({
      id: uuid.v4(),
      name: 'New lane'
    });
  };

  return (
    <div>
      <button className="add-lane" onClick={addLane}>+</button>
      <Lanes lanes={lanes} />
    </div>
  );
};

export default connect(({lanes}) => ({
  lanes
}), {
  LaneActions
})(App)

If you check out the implementation at the browser, you can see that the current implementation doesn't do much. You should be able to add new lanes to the Kanban and see "New lane" text per each but that's about it. To restore the note related functionality, we need to focus on modeling Lane further.

Modeling Lane#

Lane will render a name and associated Notes. The example below has been modeled largely after our earlier implementation of App. Replace the file contents entirely as follows:

app/components/Lane.jsx

import React from 'react';
import uuid from 'uuid';
import connect from '../libs/connect';
import NoteActions from '../actions/NoteActions';
import Notes from './Notes';

const Lane = ({
  lane, notes, NoteActions, ...props
}) => {
  const editNote = (id, task) => {
    NoteActions.update({id, task, editing: false});
  };
  const addNote = e => {
    e.stopPropagation();

    const noteId = uuid.v4();

    NoteActions.create({
      id: noteId,
      task: 'New task'
    });
  };
  const deleteNote = (noteId, e) => {
    e.stopPropagation();

    NoteActions.delete(noteId);
  };
  const activateNoteEdit = id => {
    NoteActions.update({id, editing: true});
  };

  return (
    <div {...props}>
      <div className="lane-header">
        <div className="lane-add-note">
          <button onClick={addNote}>+</button>
        </div>
        <div className="lane-name">{lane.name}</div>
      </div>
      <Notes
        notes={notes}
        onNoteClick={activateNoteEdit}
        onEdit={editNote}
        onDelete={deleteNote} />
    </div>
  );
};

export default connect(
  ({notes}) => ({
    notes
  }), {
    NoteActions
  }
)(Lane)

If you run the application and try adding new notes, you can see there's something wrong. Every note you add is shared by all lanes. If a note is modified, other lanes update too.

Duplicate notes
Duplicate notes

The reason why this happens is simple. Our NoteStore is a singleton. This means every component that is listening to NoteStore will receive the same data. We will need to resolve this problem somehow.

Making Lanes Responsible of Notes#

Currently, our Lane contains just an array of objects. Each of the objects knows its id and name. We'll need something more sophisticated.

Each Lane needs to know which Notes belong to it. If a Lane contained an array of Note ids, it could then filter and display the Notes belonging to it. We'll implement a scheme to achieve this next.

Understanding attachToLane#

When we add a new Note to the system using addNote, we need to make sure it's associated to some Lane. This association can be modeled using a method, such as LaneActions.attachToLane({laneId: <id>, noteId: <id>}). Here's an example of how it could work:

const addNote = e => {
  e.stopPropagation();

  const noteId = uuid.v4();

  NoteActions.create({
    id: noteId,
    task: 'New task'
  });
  LaneActions.attachToLane({
    laneId: lane.id,
    noteId
  });
}

This is just one way to handle noteId. We could push the generation logic within NoteActions.create and then return the generated id from it. We could also handle it through a Promise. This would be very useful if we added a back-end to our implementation. Here's how it would look like then:

const addNote = e => {
  e.stopPropagation();

  NoteActions.create({
    task: 'New task'
  }).then(noteId => {
    LaneActions.attachToLane({
      laneId: lane.id,
      noteId: noteId
    });
  })
}

Now we have declared a clear dependency between NoteActions.create and LaneActions.attachToLane. This would be one valid alternative especially if you need to go further with the implementation.

You could model the API using positional parameters and end up with LaneActions.attachToLane(laneId, note.id). I prefer the object form as it reads well and you don't have to care about the order.
Another way to handle the dependency problem would be to use Flux dispatcher related feature known as waitFor. It allows us to state dependencies on store level. It is better to avoid that if you can, though, as data management solutions like Redux make it redundant. Using Promises as above can help as well.

Setting Up attachToLane#

To get started we should add attachToLane to actions as before:

app/actions/LaneActions.js

import alt from '../libs/alt';

export default alt.generateActions(
  'create', 'attachToLane'
);

In order to implement attachToLane, we need to find a lane matching to the given lane id and then attach note id to it. Furthermore, each note should belong only to one lane at a time. We can perform a rough check against that:

app/stores/LaneStore.js

import LaneActions from '../actions/LaneActions';

export default class LaneStore {
  ...
attachToLane({laneId, noteId}) { this.setState({ lanes: this.lanes.map(lane => { if(lane.notes.includes(noteId)) { lane.notes = lane.notes.filter(note => note !== noteId); } if(lane.id === laneId) { lane.notes = lane.notes.concat([noteId]); } return lane; }) }); }
}

Just being able to attach notes to a lane isn't enough. We are also going to need some way to detach them. This comes up when we are removing notes.

We could give a warning here in case you are trying to attach a note to a lane that doesn't exist. console.warn would work nicely for that.

Setting Up detachFromLane#

We can model a similar counter-operation detachFromLane using an API like this:

LaneActions.detachFromLane({noteId, laneId});
NoteActions.delete(noteId);
Just like with attachToLane, you could model the API using positional parameters and end up with LaneActions.detachFromLane(laneId, noteId).

Again, we should set up an action:

app/actions/LaneActions.js

import alt from '../libs/alt';

export default alt.generateActions(
  'create', 'attachToLane', 'detachFromLane'
);

The implementation will resemble attachToLane. In this case, we'll remove the possibly found Note instead:

app/stores/LaneStore.js

import LaneActions from '../actions/LaneActions';

export default class LaneStore {
  ...
detachFromLane({laneId, noteId}) { this.setState({ lanes: this.lanes.map(lane => { if(lane.id === laneId) { lane.notes = lane.notes.filter(note => note !== noteId); } return lane; }) }); }
}

Given we have enough logic in place now, we can start connecting it with the user interface.

It is possible detachFromLane doesn't detach anything. If this case is detected, it would be nice to use console.warn to let the developer know about the situation.

Connecting Lane with the Logic#

To make this work, there are a couple of places to tweak at Lane:

  • When adding a note, we need to attach it to the current lane.
  • When deleting a note, we need to detach it from the current lane.
  • When rendering a lane, we need to select notes belonging to it. It is important to render then notes in the order they belong to a lane. This needs extra logic.

These changes map to Lane as follows:

app/components/Lane.jsx

import React from 'react';
import uuid from 'uuid';
import connect from '../libs/connect';
import NoteActions from '../actions/NoteActions';
import LaneActions from '../actions/LaneActions';
import Notes from './Notes'; const Lane = ({
lane, notes, NoteActions, ...props
lane, notes, LaneActions, NoteActions, ...props
}) => { const editNote = (id, task) => { ... }; const addNote = e => { e.stopPropagation(); const noteId = uuid.v4(); NoteActions.create({ id: noteId, task: 'New task' });
LaneActions.attachToLane({ laneId: lane.id, noteId });
}; const deleteNote = (noteId, e) => { e.stopPropagation();
LaneActions.detachFromLane({ laneId: lane.id, noteId });
NoteActions.delete(noteId); }; const activateNoteEdit = id => { NoteActions.update({id, editing: true}); }; return ( <div {...props}> <div className="lane-header"> <div className="lane-add-note"> <button onClick={addNote}>+</button> </div> <div className="lane-name">{lane.name}</div> </div> <Notes
notes={notes}
notes={selectNotesByIds(notes, lane.notes)}
onNoteClick={activateNoteEdit} onEdit={editNote} onDelete={deleteNote} /> </div> ); };
function selectNotesByIds(allNotes, noteIds = []) { // `reduce` is a powerful method that allows us to // fold data. You can implement `filter` and `map` // through it. Here we are using it to concatenate // notes matching to the ids. return noteIds.reduce((notes, id) => // Concatenate possible matching ids to the result notes.concat( allNotes.filter(note => note.id === id) ) , []); }
export default connect( ({notes}) => ({ notes }), {
NoteActions
NoteActions, LaneActions
} )(Lane)

If you try using the application now, you should see that each lane is able to maintain its own notes:

Separate notes
Separate notes

The current structure allows us to keep singleton stores and a flat data structure. Dealing with references is a little awkward, but that's consistent with the Flux architecture. You can see the same theme in the Redux implementation. The MobX one avoids the problem altogether given we can use proper references there.

selectNotesByIds could have been written in terms of map and find. In that case you would have ended up with noteIds.map(id => allNotes.find(note => note.id === id));. You would need to polyfill find in this case to support older browsers, though.
Normalizing the data would have made selectNotesByIds trivial. If you are using a solution like Redux, normalization can make operations like this easy.

Extracting LaneHeader from Lane#

Lane is starting to feel like a big component. This is a good chance to split it up to keep our application easier to maintain. Especially the lane header feels like a component of its own. To get started, define LaneHeader based on the current code like this:

app/components/LaneHeader.jsx

import React from 'react';
import uuid from 'uuid';
import connect from '../libs/connect';
import NoteActions from '../actions/NoteActions';
import LaneActions from '../actions/LaneActions';

export default connect(() => ({}), {
  NoteActions,
  LaneActions
})(({lane, LaneActions, NoteActions, ...props}) => {
  const addNote = e => {
    e.stopPropagation();

    const noteId = uuid.v4();

    NoteActions.create({
      id: noteId,
      task: 'New task'
    });
    LaneActions.attachToLane({
      laneId: lane.id,
      noteId
    });
  };

  return (
    <div className="lane-header" {...props}>
      <div className="lane-add-note">
        <button onClick={addNote}>+</button>
      </div>
      <div className="lane-name">{lane.name}</div>
    </div>
  );
})

We also need to connect the extracted component with Lane:

app/components/Lane.jsx

import React from 'react';
import uuid from 'uuid';
import connect from '../libs/connect'; import NoteActions from '../actions/NoteActions'; import LaneActions from '../actions/LaneActions'; import Notes from './Notes';
import LaneHeader from './LaneHeader';
const Lane = ({ lane, notes, LaneActions, NoteActions, ...props }) => { const editNote = (id, task) => { NoteActions.update({id, task, editing: false}); };
const addNote = e => { e.stopPropagation(); const noteId = uuid.v4(); NoteActions.create({ id: noteId, task: 'New task' }); LaneActions.attachToLane({ laneId: lane.id, noteId }); };
const deleteNote = (noteId, e) => { e.stopPropagation(); LaneActions.detachFromLane({ laneId: lane.id, noteId }); NoteActions.delete(noteId); }; const activateNoteEdit = id => { NoteActions.update({id, editing: true}); }; return ( <div {...props}>
<div className="lane-header"> <div className="lane-add-note"> <button onClick={addNote}>+</button> </div> <div className="lane-name">{lane.name}</div> </div>
<LaneHeader lane={lane} />
<Notes notes={selectNotesByIds(notes, lane.notes)} onNoteClick={activateNoteEdit} onEdit={editNote} onDelete={deleteNote} /> </div> ); }; ...

After these changes we have something that's a little easier to work with. It would have been possible to maintain all the related code in a single component. Often these are judgment calls as you realize there are nicer ways to split up your components. Sometimes the need for reuse or performance will force you to split.

Conclusion#

We managed to solve the problem of handling data dependencies in this chapter. It is a problem that comes up often. Each data management solution provides a way of its own to deal with it. Flux based alternatives and Redux expect you to manage the references. Solutions like MobX have reference handling integrated. Data normalization can make these type of operations easier.

In the next chapter we will focus on adding missing functionality to the application. This means tackling editing lanes. We can also make the application look a little nicer again. Fortunately a lot of the logic has been developed already.

This book is available through Leanpub. By purchasing the book you support the development of further content.

Need help?