Redux state shape for managing multiple React component instances? - reactjs

Working on a small project using React and Redux in which I'm making what is essentially a Trello clone, or kanban system. But I'm having trouble figuring out how to construct my state for Redux so that things don't get weird.
Essentially, a user needs to be able to create multiple instances of a <Board/> component via React. Each <Board/> instance has a title. Continuing to drill down, each </Board> instance can also have an array of its own <List/> component instances. Each <List/> instance can in turn have its own <Card/> component instances.
Which is where I get confused. In React, it's simple--each instance of <Board/> and each instance of <List/> just manage their own state. But I can't figure out how to refactor everything so that Redux manages all state, but each component instance receives the correct slice of state.
So far, I've constructed my Redux state to look as follows. Any help is appreciated!
{
boards: [
{
title: 'Home',
lists: [
{
title: 'To Do',
cards: [
{ title: 'Finish this project.'},
{ title: 'Start that project.'}
]
},
{
title: 'Doing',
cards: []
},
{
title: 'Done',
cards: []
}
]
}
]
}

redux is basically a one global store object. so theoretically this is no different than using react without redux but keeping the store at the most top level component's state.
Of course with redux we gain a lot of other goodies that makes it a great state manager. But for the sake of simplicity lets focus on the state structure and data flow between the react components.
Lets agree that if we have one global store to hold our single source of truth then we don't need to keep any local state inside our child components.
But we do need to break and assemble our data within our react flow, so a nice pattern is to create little bits of components that just get the relevant data an id and handlers so they can send back data to the parents with the corresponding id. This way the parent can tell which instance was the one invoking the handler.
So we can have a <Board /> that renders a <List /> that renders some <Cards /> and each instance will have it's own id and will get the data it needs.
Lets say we want to support addCard and toggleCard actions, We will need to update our store in couple level for this.
For toggling a card we will need to know:
What is the Card id that we just clicked on
What is the List id that this card belongs to
What is the Board id that this list is belong to
For adding a card we will need to know:
What is the List id that we clicked on
What is the Board id that this list is belong to
Seems like the same pattern but with different levels.
To do that we will need to pass onClick events to each component and this component will invoke it while passing it's own id to the parent, in turn the parrent will invoke it's onClick event while passing the child's id and it's own id so the next parent will know which child instances were being clicked.
For example:
Card will invoke:
this.props.onClick(this.props.id)
List will listen and will invoke:
onCardClick = cardId => this.props.onClick(this.props.id,cardId);
Board wil llisten and will invoke:
onListClick = (listId, cardId) => this.props.onClick(this.props.id, listId, cardId)
Now our App can listen as well and by this time it will have all the necessary data it needs to perform the update:
onCardToggle(boardId, listId, cardId) => dispatchToggleCard({boardId, listId, cardId})
From here its up to the reducers to do their job.
See how the components transfer data upwards, each component gather the data sent from its child and passing it upwards while adding another piece of data of itself. Small bits of data are assembled up until the top most component get all the data it needs to perform the updates to the state.
I've made a small example with your scenario, note that i'm not using redux due to the limitations of stack-snippets. I did wrote however the reducers and the entire logic of the updates and data flow, but i skipped the action creators part and connecting to an actual redux store.
I think it can give you some idea on how to structure your store, reducers and components.
function uuidv4() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
)
}
class Card extends React.Component {
onClick = () => {
const { onClick, id } = this.props;
onClick(id);
}
render() {
const { title, active = false } = this.props;
const activeCss = active ? 'active' : '';
return (
<div className={`card ${activeCss}`} onClick={this.onClick}>
<h5>{title}</h5>
</div>
);
}
}
class List extends React.Component {
handleClick = () => {
const { onClick, id } = this.props;
onClick(id);
}
onCardClick = cardId => {
const { onCardClick, id: listId } = this.props;
onCardClick({ listId, cardId });
}
render() {
const { title, cards } = this.props;
return (
<div className="list">
<button className="add-card" onClick={this.handleClick}>+</button>
<h4>{title}</h4>
<div>
{
cards.map((card, idx) => {
return (
<Card key={idx} {...card} onClick={this.onCardClick} />
)
})
}
</div>
</div>
);
}
}
class Board extends React.Component {
onAddCard = listId => {
const { onAddCard, id: boardId } = this.props;
const action = {
boardId,
listId
}
onAddCard(action)
}
onCardClick = ({ listId, cardId }) => {
const { onCardClick, id: boardId } = this.props;
const action = {
boardId,
listId,
cardId
}
onCardClick(action)
}
render() {
const { title, list } = this.props;
return (
<div className="board">
<h3>{title}</h3>
{
list.map((items, idx) => {
return (
<List onClick={this.onAddCard} onCardClick={this.onCardClick} key={idx} {...items} />
)
})
}
</div>
);
}
}
const cardRedcer = (state = {}, action) => {
switch (action.type) {
case 'ADD_CARD': {
const { cardId } = action;
return { title: 'new card...', id: cardId }
}
case 'TOGGLE_CARD': {
return {
...state,
active: !state.active
}
}
default:
return state;
}
}
const cardsRedcer = (state = [], action) => {
switch (action.type) {
case 'ADD_CARD':
return [...state, cardRedcer(null, action)];
case 'TOGGLE_CARD': {
return state.map(card => {
if (card.id !== action.cardId) return card;
return cardRedcer(card, action);
});
}
default:
return state;
}
}
const listReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_CARD': {
const { listId } = action;
return state.map(item => {
if (item.id !== listId) return item;
return {
...item,
cards: cardsRedcer(item.cards, action)
}
});
}
case 'TOGGLE_CARD': {
const { listId, cardId } = action;
return state.map(item => {
if (item.id !== listId) return item;
return {
...item,
cards: cardsRedcer(item.cards,action)
}
});
}
default:
return state;
}
}
class App extends React.Component {
state = {
boards: [
{
id: 1,
title: 'Home',
list: [
{
id: 111,
title: 'To Do',
cards: [
{ title: 'Finish this project.', id: 1 },
{ title: 'Start that project.', id: 2 }
]
},
{
id: 222,
title: 'Doing',
cards: [
{ title: 'Finish Another project.', id: 1 },
{ title: 'Ask on StackOverflow.', id: 2 }]
},
{
id: 333,
title: 'Done',
cards: []
}
]
}
]
}
onAddCard = ({ boardId, listId }) => {
const cardId = uuidv4();
this.setState(prev => {
const nextState = prev.boards.map(board => {
if (board.id !== boardId) return board;
return {
...board,
list: listReducer(board.list, { type: 'ADD_CARD', listId, cardId })
}
})
return {
...prev,
boards: nextState
}
});
}
onCardClick = ({ boardId, listId, cardId }) => {
this.setState(prev => {
const nextState = prev.boards.map(board => {
if (board.id !== boardId) return board;
return {
...board,
list: listReducer(board.list, { type: 'TOGGLE_CARD', listId, cardId })
}
})
return {
...prev,
boards: nextState
}
});
}
render() {
const { boards } = this.state;
return (
<div className="board-sheet">
{
boards.map((board, idx) => (
<Board
id={board.id}
key={idx}
list={board.list}
title={board.title}
onAddCard={this.onAddCard}
onCardClick={this.onCardClick}
/>
))
}
</div>
);
}
}
ReactDOM.render(<App />, document.getElementById('root'));
.board-sheet{
padding: 5px;
}
.board{
padding: 10px;
margin: 10px;
border: 1px solid #333;
}
.list{
border: 1px solid #333;
padding: 10px;
margin: 5px;
}
.card{
cursor: pointer;
display: inline-block;
overflow: hidden;
padding: 5px;
margin: 5px;
width: 100px;
height: 100px;
box-shadow: 0 0 2px 1px #333;
}
.card.active{
background-color: green;
color: #fff;
}
.add-card{
cursor: pointer;
float: right;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="root"></div>

Related

Redux updating object in an array (React-Native)

Trying to learn Redux. I am building a list app. From the home screen you can see all your lists and click on one to update. You can also create a new list.
So I've made a check to see if you navigate to the list component with data, the action upon 'save' will be UPDATE_LIST. If you navigate to the list component with no data, the action upon 'save' will be NEW_LIST. The new list works but the update does not. If you need to see more files, let me know. Thank you.
This is the list component:
import React from 'react';
import { StyleSheet, Text, View, Button, TextInput } from 'react-native';
import { connect } from 'react-redux';
import { newList, updateList } from '../store/tagActions';
class List extends React.Component {
constructor(props){
super(props);
this.state = {
title: '',
tags: [],
mentions: [],
tagValue: '',
mentionValue: '',
id: null
}
}
submitTag = (text) => {
this.setState({
tags: [
...this.state.tags,
text
],
tagValue: ''
})
}
submitMention = (text) => {
this.setState({
mentions: [
...this.state.mentions,
text
],
mentionValue: ''
})
}
componentDidMount() {
if (this.props.route.params.data !== null) {
const { title, tags, mentions, id } = this.props.route.params
this.setState({
id: id,
title: title,
tags: tags,
mentions: mentions
})
} else return
}
save = () => {
if (this.props.route.params.data !== null) {
this.props.updateList(
id = this.state.id,
title = this.state.title,
tags = this.state.tags,
mentions = this.state.mentions
)
} else {
this.props.newList(
title = this.state.title,
tags = this.state.tags,
mentions = this.state.mentions
)
}
this.props.navigation.navigate('Home');
}
render() {
return (
<View style={styles.container}>
<TextInput //==================================== TITLE
value={this.state.title}
style={styles.title}
placeholder='add Title..'
onChangeText={text => this.setState( {title: text} ) }
/>
<View style={styles.allTags}>
<Text>{this.state.id}</Text>
<View style={styles.tagsList}>
{
this.state.tags.map((tag => (
<Text key={tag} style={styles.tags}>#{tag}</Text>
)))
}
</View>
<View style={styles.mentionsList}>
{
this.state.mentions.map((mention => (
<Text key={mention} style={styles.mentions}>#{mention}</Text>
)))
}
</View>
</View>
<TextInput // =================================== TAGS
value={ this.state.tagValue }
style={styles.tagsInput}
placeholder='add #Tags..'
placeholderTextColor = "#efefef"
autoCorrect = { false }
autoCapitalize = 'none'
onChangeText={text => this.setState( {tagValue: text}) }
onSubmitEditing={() => this.submitTag(this.state.tagValue)}
/>
<TextInput //===================================== MENTIONS
value={ this.state.mentionValue }
style={styles.mentionsInput}
placeholder='add #Mentions..'
placeholderTextColor = "#efefef"
autoCorrect = { false }
autoCapitalize = 'none'
onChangeText={text => this.setState( {mentionValue: text})}
onSubmitEditing= {() => this.submitMention(this.state.mentionValue)}
/>
<Button
title='save'
onPress={() => {
this.save();
}
}
/>
</View>
)
}
}
const mapStateToProps = (state) => {
return { state }
};
export default connect(mapStateToProps, { newList, updateList }) (List);
tagActions.js
let nextId = 0;
export const newList = (title, tags, mentions) => (
{
type: 'NEW_LIST',
payload: {
id: ++nextId,
title: title,
tags: tags,
mentions: mentions
}
}
);
export const updateList = (title, tags, mentions, id) => (
{
type: 'UPDATE_LIST',
payload: {
id: id,
title: title,
tags: tags,
mentions: mentions
}
}
);
tagReducer.js:
const tagReducer = (state = [], action) => {
switch (action.type) {
case 'NEW_LIST':
//add tags and mentions later
const { id, title, tags, mentions } = action.payload;
return [
...state,
{
id: id,
title: title,
tags: tags,
mentions: mentions
}
]
case 'UPDATE_LIST':
return state.map((item, index) => {
if (item.id === action.payload.id) {
return {
...item,
title: action.payload.title,
tags: action.payload.tags,
mentions: action.payload.mentions
}
} else { return item }
})
default:
return state;
}
};
export default tagReducer;
By sending args like so
export const updateList = (title, tags, mentions, id) => (
In the scope of the function, the first arg that the function will be called with gonna be title, and even by doing something like this
this.props.updateList(
id = this.state.id,
title = this.state.title,
tags = this.state.tags,
mentions = this.state.mentions
)
what you sent as this.state.id, gonna be evaluate as title. (not python alert)
so you have two options, either organize args as in function, or send object with keys
this.props.updateList({
id: this.state.id,
title: this.state.title,
tags: this.state.tags,
mentions: this.state.mentions
})
export const updateList = ({title, tags, mentions, id}) => (
Anyhow, of course you can use array as data structure for state, sorry I mislead you
const tagReducer = (state = [], action) => {
switch (action.type) {
const { id, title, tags, mentions } = action.payload || {};
case 'NEW_LIST':
//add tags and mentions later
return [ ...state, { id, title, tags, mentions } ]
case 'UPDATE_LIST':
return state.map(item =>
item.id === id ? { ...item, title, tags, mentions} : item
)
default: return state;
}
};
export default tagReducer;

How to execute a function when some item renders in react native?

I have a sectionlist of Contacts where I am displaying both device and online contacts of a user. The online contacts api doesnt give me all the contacts at once. So I have to implement some pagination. I am also fetching all device contacts and first page of online contacts and sorting them to show in sectionlist, but the problem is, to load more contacts, i have to keep track of the last item rendered in my state and in the render function I am calling pagination function to load more contacts. and then i am updating the state of fetched online contact. But its an unsafe operation, is there a better way to achieve this?
I want to execute a function when the specific item renders and it can update the state.
Here is some code: ContactList.tsx
import React, { Component } from "react";
import {
View,
StyleSheet,
SectionListData,
SectionList,
Text
} from "react-native";
import { Contact } from "../../models/contact";
import ContactItem from "./contact-item";
export interface ContactsProps {
onlineContacts: Contact[];
deviceContacts: Contact[];
fetchMoreITPContacts: () => void;
}
export interface ContactsState {
loading: boolean;
error: Error | null;
data: SectionListData<Contact>[];
lastItem: Contact;
selectedItems: [];
selectableList: boolean;
}
class ContactList extends Component<ContactsProps, ContactsState> {
private sectionNames = [];
constructor(props: ContactsProps, state: ContactsState) {
super(props, state);
this.state = {
loading: false,
error: null,
data: [],
lastItem: this.props.onlineContacts[this.props.onlineContacts.length - 1]
};
for (var i = 65; i < 91; ++i) {
this.sectionNames.push({
title: String.fromCharCode(i),
data: []
});
}
}
private buildSectionData = contacts => {
this.sort(contacts);
const data = [];
const contactData = this.sectionNames;
contacts.map(contact => {
const index = contact.name.charAt(0).toUpperCase();
if (!data[index]) {
data[index] = [];
contactData.push({
title: index,
data: []
})
}
data[index].push(contact);
});
for (const index in data) {
const idx = contactData.findIndex(x => x.title === index);
contactData[idx].data.push(...data[index]);
}
this.setState({
loading: false,
error: null,
lastItem: contacts[contacts.length - 1],
data: [...contactData]
});
};
private sort(contacts) {
contacts.sort((a, b) => {
if (a.name > b.name) {
return 1;
}
if (b.name > a.name) {
return -1;
}
return 0;
});
}
componentDidMount() {
const contacts = [].concat(
this.props.deviceContacts,
this.props.onlineContacts
);
this.buildSectionData(contacts);
}
componentDidUpdate(
prevProps: Readonly<ContactsProps>,
prevState: Readonly<ContactsState>,
snapshot?: any
): void {
if (this.props.onlineContacts !== prevProps.onlineContacts) {
const from = this.props.itpContacts.slice(
prevProps.onlineContacts.length,
this.props.onlineContacts.length
);
this.buildSectionData(from);
}
}
renderItem(item: any) {
if (!!this.state.lastItem && !this.state.loading)
if (item.item.id === this.state.lastItem.id) {
this.setState({
loading: true
});
this.props.fetchMoreOnlineContacts();
}
return <ContactItem item={item.item} />;
}
render() {
return (
<View style={styles.container}>
<SectionList
sections={this.state.data}
keyExtractor={(item, index) => item.id}
renderItem={this.renderItem.bind(this)}
renderSectionHeader={({ section }) =>
section.data.length > 0 ? (
<Text style={styles.sectionTitle}>
{section.title}
</Text>
) : null
}
/>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
sectionTitle: {
paddingBottom: 30,
paddingLeft: 25,
fontWeight: "bold",
fontSize: 20
}
});
export default ContactList;
Yeah after some thoughts I got the answer may be.
instead of calling fetchMoreContacts from renderItem I passed the lastItem as a prop to the ContactItem component.
and in the constructor I checked If the item is lastItem and called to fetchMoreContact.
and it worked!

How to set state with spread operators, when using variables for keys?

I have a certain piece of state like:
this.state = {
receipts: {
qwer12r: {color: 'red', size: '20'}
qas123e: {color: 'green', size: '21'}
}
};
these values are collected from two different forms. hence the keys correspond to different form IDs. The forms have drop-downs from where I successfully return the formID and the selected value.
But, using the formID, I want to burrow into the formID object and update only one of the properties.
The components are structured such that the children are the forms, and the parent is a form creator / duplicator.
This implies that the change functions need to be passed down to the children to retrieve their formID, and correspondingly update the correct state, belonging to the correct form.
I have tried the spread operator in many different ways, all having failed to produce the expected result.
In the parent
Here's what I've already tried:
handleChange(formNumber, value) {
this.setState(prevState => ({
receipts: {
...prevState.receipts,
[formNumber]: {
...prevState.formNumber,
color: value
}
}
}));
and this:
handleChange(formNumber, value) {
this.setState(prevState => ({
receipts: {
...prevState.receipts,
[formNumber]: {
...`prevState.receipts.${formNumber}`,
color: value
}
}
}));
and this:
handleChange(formNumber, value) {
this.setState(prevState => ({
receipts: {
...prevState.receipts,
[formNumber]: {
...`prevState.${formNumber}`,
color: value
}
}
}));
In the Child:
Here is handler attached to the onChange of the color dropdown in the form:
onChange={value =>this.handleValueChange(this.props.formNumber, value)}
and here is that method definition:
handleValueChange= (formId, value) => {
this.props.handleformColorChange(formId, value);
};
If the child form calls the handleChange method like handleChange('qwer12r', 'blue'),
the expected result:
this.state = {
receipts: {
qwer12r: {color: 'blue', size: '20'}
qas123e: {color: 'green', size: '21'}
}
};
Access receipts with bracket notation
handleChange(formNumber, value) {
this.setState(prevState => ({
receipts: {
...prevState.receipts,
[formNumber]: {
...prevState.receipts[formNumber],
color: value
}
}
}));
This might be helpful.
handleChange(formId, value) = () => {
this.setState({
[formId] : {
...this.state[formId],
color: value
}
)}
}
You can retrieve the property by bracket notation, Below is the simple implementation for same.
class Parent extends Component {
constructor() {
super()
this.state = { receipts: { firstForm: { color: "" } } }
this.handleChange = this.handleChange.bind(this);
}
handleChange(formNumber, value) {
this.setState(prevState => ({
receipts: {
...prevState.receipts,
[formNumber]: {
...prevState[formNumber],
color: value
}
}
}));
}
render() {
console.log(this.state)
return (
<Child formNumber="firstForm"
handleformColorChange={this.handleChange}
value={this.state.receipts.firstForm.color}></Child>
)
}
}

React Select with Custom MultiValueContainer and Apollo Mutation not Cooperating

I have a custom component that uses Apollo and React-Select and has two mutations (see below). The react-select is multivalue and needs to be custom because I need an "isSelected" checkbox on it. Not shown in this code, but the initial options list is passed in from the parent container.
The parent Select and its mutation work as expected. However, I'm running into a couple of odd problems with the custom MultiValueContainer. The first is that the first time I select any of the check-boxes, I get an error saying that "Can't call setState (or forceUpdate) on an unmounted component." Note below that I have an empty componentWillUnmount function just to see if it gets called, which it doesn't. But (I assume) as a result of that, when the "toggleThing" mutation is called, the state doesn't have the vars necessary to complete the request. The second time I click it works as expected, with the exception of the second issue.
The second issue is that the onCompleted function on the MultiValueContainer mutation never fires, so even though the server is returning the expected data, it never seems to get back to the mutation and therefore never to the component. The onCompleted function on the parent Select works as expected.
Thanks in advance for any insights anyone might have. Perhaps needless to say, I am relatively new to react/apollo/react-select and apologize in advance for any newbie mistakes. Also, I've tried to scrub and simplify the code so apologies also for any renaming mistakes.
const UPDATE_THINGS = gql`
mutation UpdateThings(
$id: ID!
$newThings: [ThingInput]
) {
updateThings(
id: $id
newThings: $newThings
) {
id
}
}
`;
const TOGGLE_THING = gql`
mutation ToggleThing($id: ID!, $isChecked: Boolean) {
toggleThing(
id: $id
isChecked: $isChecked
) {
id
}
}
`;
class ThingList extends Component {
stylesObj = {
multiValue: base => {
return {
...base,
display: 'flex',
alignItems: 'center',
paddingLeft: '10px',
background: 'none',
border: 'none'
};
}
};
constructor(props) {
super(props);
this.state = {
selectedThings: [],
selectedThingId: '',
selectedThingIsChecked: false
};
}
onUpdateComplete = ({ updateThings }) => {
console.log('onUpdateComplete');
console.log('...data', updateThings );
this.setState({ selectedThings: updateThings });
};
onToggleThing = (thingId, isChecked, toggleThing) => {
console.log('onToggleThing, thingId, isChecked');
this.setState(
{
selectedThingId: thingId,
selectedThingIsChecked: isHighPisCheckedoficiency
},
() => toggleThing()
);
};
onToggleThingComplete = ({ onToggleThing }) => {
console.log('onToggleThingComplete ');
console.log('...data', onToggleThing );
this.setState({ selectedThings: onToggleThing });
};
handleChange = (newValue, actionMeta, updateThings) => {
this.setState(
{
selectedThings: newValue
},
() => updateThings()
);
};
isThingSelected = thing=> {
return thing.isSelected;
};
getSelectedThings = selectedThings => {
console.log('getSelectedSkills');
return selectedThings ? selectedThings.filter(obj => obj.isSelected) : [];
};
componentWillUnmount() {
console.log('componentWillUnmount');
}
render() {
const self = this;
const MultiValueContainer = props => {
// console.log('...props', props.data);
return (
<Mutation
mutation={ TOGGLE_THING }
onCompleted={self.onToggleThingComplete}
variables={{
id: self.state.selectedThingId,
isChecked: self.state.selectedThingIsChecked
}}>
{(toggleThing, { data, loading, error }) => {
if (loading) {
return 'Loading...';
}
if (error) {
return `Error!: ${error}`;
}
return (
<div className={'option d-flex align-items-center'}>
<input
type={'checkbox'}
checked={props.data.isChecked}
onChange={evt => {
self.onToggleThing(
props.data.id,
evt.target.checked,
toggleIsHighProficiency
);
}}
/>
<components.MultiValueContainer {...props} />
</div>
);
}}
</Mutation>
);
};
return (
<Mutation
mutation={UPDATE_THINGS}
onCompleted={this.onUpdateComplete}
variables={{ id: this.id, newThings: this.state.selectedThings}}>
{(updateThings, { data, loading, error }) => {
if (loading) {
return 'Loading...';
}
if (error) {
return `Error!: ${error}`;
}
return (
<div>
<Select
options={this.props.selectedThings}
styles={this.stylesObj}
isClearable
isDisabled={this.props.loading}
isLoading={this.props.loading}
defaultValue={this.props.selectedThings.filter(
obj => obj.isSelected
)}
isOptionSelected={this.isOptionSelected}
isMulti={true}
onChange={(newValue, actionMeta) =>
this.handleChange(
newValue,
actionMeta,
updateThings
)
}
components={{
MultiValueContainer
}}
/>
</div>
);
}}
</Mutation>
);
}
}
export default ThingsList;
You are redefining MultiValueContainer on every render, which is not a good practice and may cause unexpected behavior. Try moving it into separate component to see if it helps.

Store ui related properties outside of the component state

I've moved from using pure jsons in react to instances.
Meaning, I store instances inside the component state.
The problem is that the instances can't update their own properties because it means mutating the state so I need to clone the instance, update from outside of the instance, and then set state.
I wanted to store the instances on the component and store a dummy state property that once an instance property changes it updates the dummy state that will trigger a re-render.
Since the instances are related to the UI I know they should reside in the state.
I know it's not the best practice but I'm trying to figure out how bad is it.
For example:
class Team {
constructor(data) {
this._teamMates = null;
this.id = data.id;
}
get teamMates() {
if (!this._teamMates) {
this.fetchTeamMates();
return null;
}
else
return this._teamMates;
}
fetchTeamMates() {
fetch('/teamMates/' + this.id).then(teamMates => this._teamMates = teamMates);
}
}
When we first try to get teamMated they are not valid so we fetch them and on the next state change I want them to be valid as teamMates.
I can return a promise instead of null, it is possible but I want to handle it this way because I want to have conditional rendering if we have the teamMates.
I know there are multiple ways to solve this thing but I rather try to find a way to make it work without returning a promise.
I'm not exactly sure what you mean by:
"The problem is that the instances can't update their own properties because it means mutating the state so I need to clone the instance, update from outside of the instance, and then set state."
It sounds like the simplest thing to do is create the state and method in a parent HOC, pass them to a child component and fire the action in an event handler, it should then update the parents state and cause the normal render in the child. Ideally your child would be a "dumb component", literally just passing the values and actions and rendering.
I think you are looking for some state manager, like redux for example (you didn't exclude it from your question)
If you really want to keep your own instances (what I don't see as a necessity, as it is in no way necessary, you are handling with state), then you should create an immutable version of your classes, meaning, if you update a player inside your team, you should return a new version of your team instance, with the changed player data.
The below example doesn't use an instance, but it moves the team mates to a store, and lets redux manage the state (so it removes it from component state).
Main key points to focus on would be:
The connect statement, connecting a component with a (part of) state injected in its props, and adding a dispatcher that handles the actions you dispatch to it, looking like this:
const ConnectedTeamEditor = connect( teamStateToProps, playerDispatcher )( TeamEditor );
This requires then ofcourse a state mapper and the dispatcher, which looks pretty much like this:
// dispatcher for the actions
const playerDispatcher = dispatch => ({
fetch() {
return getTeamMates().then( response => dispatch( { type: 'loaded', payload: response } ) );
},
update( player ) {
dispatch( { type: 'update', payload: player } );
}
});
// state mapper, sharing teamMates state over the connected components props
const teamStateToProps = state => ({ teamMates: state.teamMates });
To handle the dispatch calls, we would need a reducer that is then also registered into a store, and we use the createStore method provided by react-redux. The reducer can have a default state
// reducer for player actions
const playerReducer = ( state = { teamMates: null }, action ) => {
switch ( action.type ) {
case 'loaded':
return { teamMates: action.payload };
case 'update':
return { teamMates: state.teamMates.map( player => player.id === action.payload.id ? action.payload : player ) };
default:
return state;
}
};
// and registration to store
const appStore = createStore( playerReducer );
React-redux will automatically update the affected components, once their state gets updated. Important to note is that state shouldn't be mutated, it should be replaced when required. It is also important that the state is returned when no matching action.type was found.
Finally, the application should be wrapped with a Provider that receives the store through its props, and that would then look like that.
ReactDOM.render( <Provider store={appStore}><ConnectedTeamEditor /></Provider>, target );
I do keep a component state, but only for updating the selected player at the time.
As most of us are in WK mood, I kinda went freestyle on the design, just run the code to see what I mean ^_^
const { createStore } = Redux;
const { Provider, connect } = ReactRedux;
// data provider
function getTeamMates() {
return Promise.resolve([
{ id: 1, firstName: 'Romelu', lastName: 'Lukaku', position: 'forward' },
{ id: 2, firstName: 'Dries', lastName: 'Mertens', position: 'forward' },
{ id: 3, firstName: 'Eden', lastName: 'Hazard', position: 'forward' },
{ id: 4, firstName: 'Radja', lastName: 'Naingolan', position: 'midfield' },
{ id: 5, firstName: 'Kevin', lastName: 'De Bruyne', position: 'midfield' },
{ id: 6, firstName: 'Jordan', lastName: 'Lukaku', position: 'defender' },
{ id: 7, firstName: 'Axel', lastName: 'Witsel', position: 'midfield' },
{ id: 8, firstName: 'Vincent', lastName: 'Kompany', position: 'defender' },
{ id: 9, firstName: 'Thomas', lastName: 'Meunier', position: 'defender' },
{ id: 10, firstName: 'Marouane', lastName: 'Fellaini', position: 'midfield' },
{ id: 11, firstName: 'Thibaut', lastName: 'Courtois', position: 'Goalie' }
]);
}
// some container to translate the properties to readable names
const labels = {
'id': '#',
'firstName': 'First Name',
'lastName': 'Last Name',
'position': 'Position'
};
// small function to return the correct translation or default value
const translateProperty = ( property ) => labels[property] || property;
// a field that is either editable or just shows a span with a value
const Field = ({ isEditable, value, onChange }) => {
if ( isEditable ) {
return <span className="value">
<input type="text" value={ value } onChange={ e => onChange( e.target.value ) } />
</span>;
}
return <span className="value">{ value }</span>;
};
// a single player, delegating changes to its data and selection style
const Player = ({ player, isSelected, onChange, onSelect }) => {
return (
<div className={classNames('row', { isSelected })} onClick={ () => !isSelected && onSelect && onSelect( player ) }>
{ Object.keys( player ).map( property => (
<div className="cell" key={property}>
<span className="label">{ translateProperty( property ) }</span>
<Field
isEditable={isSelected && property !== 'id' }
value={ player[property] }
onChange={ newValue => onChange( {...player, [property]: newValue } ) }
/>
</div>
) ) }
</div>
);
};
// reducer for player actions
const playerReducer = ( state = { teamMates: null }, action ) => {
switch ( action.type ) {
case 'loaded':
return { teamMates: action.payload };
case 'update':
return { teamMates: state.teamMates.map( player => player.id === action.payload.id ? action.payload : player ) };
case 'add':
return { teamMates: state.teamMates.concat( [ {
id: state.teamMates.reduce( (c, i) => c > i.id ? c : i.id, 0 ) + 1,
...action.payload } ] ) };
default:
return state;
}
};
// dispatcher for the actions
const playerDispatcher = dispatch => ({
fetch() {
return getTeamMates().then( response => dispatch( { type: 'loaded', payload: response } ) );
},
update( player ) {
dispatch( { type: 'update', payload: player } );
},
add( player ) {
dispatch( { type: 'add', payload: player } );
}
});
const teamStateToProps = state => ({ teamMates: state.teamMates });
// the team editor that works with props
class TeamEditor extends React.Component {
constructor() {
super();
this.state = {
selectedPlayer: null,
newPlayer: null
};
}
componentWillMount() {
// load when mounting
this.props.fetch();
}
selectPlayer( player ) {
// component state keeps the selected player
this.setState( { selectedPlayer: player } );
}
updatePlayer( player ) {
// update player through dispatcher
this.props.update( player );
}
onAddClicked() {
this.setState( {
isAdding: true,
newPlayer: {
firstName: '',
lastName: '',
position: ''
}
} );
}
updateNewPlayer( player ) {
this.setState( { newPlayer: player } );
}
savePlayer() {
this.setState( { isAdding: false } , () => this.props.add( this.state.newPlayer ) );
}
cancelChanges() {
this.setState( { isAdding: false } );
}
render() {
const { teamMates} = this.props;
const { selectedPlayer, isAdding, newPlayer } = this.state;
return (
<div className="team">
<div className="row">
{ !isAdding && <button
type="button"
onClick={ () => this.onAddClicked() }>Add team member</button>
}
{ isAdding && <span>
<button
type="button"
onClick={ () => this.savePlayer() }>Save</button>
<button
type="button"
onClick={ () => this.cancelChanges() }>Cancel</button>
</span> }
</div>
{ isAdding && <div className="row">
<Player
isSelected
player={newPlayer}
onChange={ (...args) => this.updateNewPlayer( ...args ) } />
</div> }
{ teamMates && teamMates.map( player => (
<Player
key={player.id}
isSelected={ !isAdding && selectedPlayer && selectedPlayer.id === player.id }
player={player}
onChange={ (...args) => this.updatePlayer( ...args ) }
onSelect={ (...args) => this.selectPlayer( ...args ) }
/> ) ) }
</div>
);
}
}
// connect the teameditor with state and dispatcher
const ConnectedTeamEditor = connect( teamStateToProps, playerDispatcher )( TeamEditor );
// create a simple store, no middleware
const appStore = createStore( playerReducer );
const target = document.querySelector('#container');
ReactDOM.render( <Provider store={appStore}><ConnectedTeamEditor /></Provider>, target );
* { box-sizing: border-box; }
body { margin: 0; padding: 0; }
.row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-evenly;
align-content: center;
}
.row:hover {
cursor: pointer;
}
.row.isSelected > .cell > * {
background-image: linear-gradient( rgba( 225, 225, 225, 0.7 ), rgba( 255, 255, 255, 0.9 ) );
}
.cell {
display: flex;
flex-direction: column;
flex-basis: 25%;
flex-grow: 0;
flex-shrink: 0;
align-self: flex-start;
background-color: yellow;
}
.cell:first-child {
background-color: red;
}
.cell:last-child {
background-color: black;
color: #fff;
}
.cell > span {
padding: 5px;
}
.cell > span > input {
width: 100%;
}
.label {
text-transform: capitalize;
}
<script id="react" src="https://cdnjs.cloudflare.com/ajax/libs/react/15.6.2/react.js"></script>
<script id="react-dom" src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/15.6.2/react-dom.js"></script>
<script id="classnames" src="https://cdnjs.cloudflare.com/ajax/libs/classnames/2.2.5/index.js"></script>
<script id="redux" src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.7.2/redux.js"></script>
<script id="react-redux" src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/5.0.6/react-redux.js"></script>
<div id="container"></div>

Resources