0

I have two components that share some identical methods, that will most probably not change anymore, and if so, then they'll change for both components. That's why I'd like to reduce redundancy here.

But these methods need to bind to this, because they access props and state, e.g. like this one:

updateProductFavorites = (product_key, action) => {
    Meteor.call('Accounts.updateProductFavorites', product_key, action, (err, response) => {
        if (err)
            makeAlert(err.reason, 'danger', 3000)
        else 
            this.getProductsByKeys()    
    })
}

The two components are quite huge, so I'd like to keep them seperate, that is conditional rendering is not an option for sharing the methods. The two components need to be called upon by different routes. I don't want to pass methods from a parent component either, as there is no need for a parent component in this case.

Ideally I'd like to keep the methods in a separate file. But how can I properly bind them to the component after importing them? Or is there a completely different approach?

This question has been asked as a comment, but not been answered satisfyingly.


Edit: I learned what a HOC (Higher Order Component) is. As soon as I've learned how to implement them in my concrete case, I'll post an answer. Feel free to help me. I've posted my two components below.

import { Meteor } from 'meteor/meteor';
import React, { Component } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { Session } from 'meteor/session';
import makeAlert from '../makeAlert';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';


class ProductFavorites extends Component {
    constructor() {
        super()

        this.state = {
            products: [],
            productDetails: true,
            singleProductDetails: 0,
        }


    }
    
    updateProductFavorites = (product_key, action) => {
        Meteor.call('Accounts.updateProductFavorites', product_key, action, (err, response) => {
            if (err)
                makeAlert(err.reason, 'danger', 3000)
            else 
                this.getProductsByKeys()    
        })
    }

    toggleProductFavorite = (product_key) => {
        const { productFavorites } = this.props.user
        if (productFavorites.includes(product_key))
            this.updateProductFavorites(product_key, 'remove')
        else
            this.updateProductFavorites(product_key, 'add')
    }

    toggleSingleProductDetails = (order_number) => {
        const { singleProductDetails: current_product } = this.state
        order_number = current_product == order_number ? 0 : order_number
        this.setState({singleProductDetails: order_number})
    }

    toggleProductDetails = () => {
        this.setState((prevState) => ({productDetails: !prevState.productDetails}))
    }

    getProductsByKeys = () => {
        Meteor.call('Products.getByProductKey', (err, response) => {
            if (err)
                makeAlert(err.reason, 'danger', 3000)
            else 
                this.setState({products: response})
        })
    }

    mapProductFavorites = () => {
        const { products, productDetails, singleProductDetails } = this.state
        const { productFavorites } = this.props.user
        if (products.length == 0)
            return <div className="alert alert-primary col-12">You haven't favorited any products at the moment.</div>
        return (
            products.map((product, i) => {
                if (product.price_100_g_ml) {
                    var [euro, cent] = product.price_100_g_ml.toFixed(2).toString().split('.')
            }

            const { product_name, units, trading_unit, certificate, origin, order_number, supplierId } = product
            const isFavorite = productFavorites.includes(`${supplierId}_${order_number}`) ? 'is-favorite' : 'no-favorite'

            return (
                <div className="col-lg-4" key={i}>
                    <div key={i} className="product-card">
                        <div className="card-header" onClick={() => this.toggleSingleProductDetails(order_number)}>
                            {product_name}
                        </div>
                        {productDetails || singleProductDetails == order_number ?
                        <>
                            <div className="card-body">
                                {euro ?
                                    <>
                                        <div className="product-actions">
                                            <button className={`btn btn-light btn-lg product-${isFavorite}`}
                                                onClick={() => this.toggleProductFavorite(`${supplierId}_${order_number}`)}>
                                                <FontAwesomeIcon icon="heart"/>
                                            </button>
                                        </div>
                                        <div className="price-100-g-ml">
                                            <small>pro 100{units == 'kg' ? 'g' : 'ml'}</small><sup></sup>
                                            <big>{euro}</big>.<sup>{cent.substring(0,2)}</sup>
                                        </div>
                                    </> : null}
                            </div>
                            <div className="card-footer">
                                <div className="row">
                                    <div className="col-4">{trading_unit}</div>
                                    <div className="col-4 text-center">{certificate}</div>
                                    <div className="col-4 text-right">{origin}</div>
                                </div>                            
                            </div>
                        </> : null }
                    </div>
                </div>)
            })
        )
    }

    

    componentWillMount() {
        this.getProductsByKeys()
    }

    render() {
        const { isLoading } = this.props
        if (isLoading)
            return null
        const { productFavorites } = this.props.user
        console.log(productFavorites)
        return(
            <div className="container app-content product-favorites">
                <div className="row mt-3">
                    {this.mapProductFavorites()}
                </div>
            </div>
        )
    }
}

export default withTracker(() => {
    return {
        user: Meteor.user(),
        isLoading: !Meteor.user()
    }
})(ProductFavorites)


import { Meteor } from 'meteor/meteor';
import React, { Component } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { Session } from 'meteor/session';
import makeAlert from '../makeAlert';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';


class ProductCatalog extends Component {
    constructor() {
        super()

        this.state = {
            categoriesBySupplier: [],
            productsFromCategory: [],
            supplierSection: {
                'supplier_0': true
            },
            productDetails: false,
            singleProductDetails: 0,
        }
    }

    updateProductFavorites = (product_key, action) => {
        Meteor.call('Accounts.updateProductFavorites', product_key, action, (err, response) => {
            if (err)
                makeAlert(err.reason, 'danger', 3000)
        })
    }
   

    getProductsFromCategoryOfSupplier = (supplierId, category1) => {
        // console.log(supplierId, category1)
        Meteor.call('Products.getFromCategory.ofSupplier', supplierId, category1, (err, response) => {
            if (err)
                makeAlert(err.reason, "danger", 3000)
            else
                this.setState({productsFromCategory: response})
        })
    }

    getProductCategories = () => {
        Meteor.call('Products.getCategories', (err, response) => {
            if (err)
                makeAlert(err.reason, "danger", 3000)
            else {
                this.setState({categoriesBySupplier: response})
                this.getProductsFromCategoryOfSupplier(0, response[0].category1[0])
            }
        })
    }

    productCategories = ({_id, category1}) => {
        return (
            category1.map((category, i) =>
                <button className="btn btn-primary btn-sm mr-1 mb-1" onClick={() => this.getProductsFromCategoryOfSupplier(_id, category)} key={i}>
                    {category}
                </button>)
        )
    }

    productsFromCategory = () => {
        const { productsFromCategory, productDetails, singleProductDetails } = this.state
        let { productFavorites } = this.props.user
        productFavorites = productFavorites == undefined ? [] : productFavorites
        // console.log(productsFromCategory, productFavorites)
        return (
            productsFromCategory.map((product, i) => {
                if (product.price_100_g_ml) {
                    var [euro, cent] = product.price_100_g_ml.toFixed(2).toString().split('.')
            }

            const { product_name, units, trading_unit, certificate, origin, order_number, supplierId } = product
            const isFavorite = productFavorites.includes(`${supplierId}_${order_number}`) ? 'is-favorite' : 'no-favorite'

            return (
                <div className="col-lg-4" key={i}>
                    <div key={i} className="product-card">
                        <div className="card-header" onClick={() => this.toggleSingleProductDetails(order_number)}>
                            {product_name}
                        </div>
                        {productDetails || singleProductDetails == order_number ?
                        <>
                            <div className="card-body">
                                {euro ?
                                    <>
                                        <div className="product-actions">
                                            <button className={`btn btn-light btn-lg product-${isFavorite}`}
                                                onClick={() => this.toggleProductFavorite(`${supplierId}_${order_number}`)}>
                                                <FontAwesomeIcon icon="heart"/>
                                            </button>
                                        </div>
                                        <div className="price-100-g-ml">
                                            <small>pro 100{units == 'kg' ? 'g' : 'ml'}</small><sup></sup>
                                            <big>{euro}</big>.<sup>{cent.substring(0,2)}</sup>
                                        </div>
                                    </> : null}
                            </div>
                            <div className="card-footer">
                                <div className="row">
                                    <div className="col-4">{trading_unit}</div>
                                    <div className="col-4 text-center">{certificate}</div>
                                    <div className="col-4 text-right">{origin}</div>
                                </div>                            
                            </div>
                        </> : null }
                    </div>
                </div>)
            })
        )
    }

    toggleSupplierSection = (event) => {
        const supplier = event.currentTarget.id
        this.setState((prevState) => ({supplierSection: {[supplier]: !prevState.supplierSection[supplier]}}))
    }

    toggleProductDetails = () => {
        this.setState((prevState) => ({productDetails: !prevState.productDetails}))
    }

    toggleSingleProductDetails = (order_number) => {
        const { singleProductDetails: current_product } = this.state
        order_number = current_product == order_number ? 0 : order_number
        this.setState({singleProductDetails: order_number})
    }

    toggleProductFavorite = (product_key) => {
        const { productFavorites } = this.props.user
        if (productFavorites.includes(product_key))
            this.updateProductFavorites(product_key, 'remove')
        else
            this.updateProductFavorites(product_key, 'add')
    }

    supplierSection = (supplier) =>
        <>
            {this.productCategories(supplier)}
            {<div className="row mt-3">{this.productsFromCategory()}</div>}
        </>
        
        
    mapSupplierSections = () => {
        const { categoriesBySupplier, supplierSection } = this.state
        
        if (categoriesBySupplier.length < 1)
            return null
        return categoriesBySupplier.map(supplier => {
            var icon = 'caret-up'
            var supplierId = supplierSection["supplier_" + supplier._id]
            if (supplierId != undefined) {
                var expand = supplierSection["supplier_" + supplier._id]
                icon = expand ? 'caret-up' : 'caret-down'
            }
            return (
                <div key={supplier._id} className="col-12">
                    <div className="input-group input-group-lg mb-3">
                        <div className="input-group-prepend">
                            <span className="input-group-text supplier-name">{supplier.supplierName}</span>
                        </div>
                        <div className="input-group-append">
                            <button className="btn btn-secondary" id={"supplier_" + supplier._id} onClick={this.toggleSupplierSection}>
                                <FontAwesomeIcon icon={icon} className="toggle-supplier-section"/>
                            </button>
                            <button className="btn btn-primary" id={"supplier_" + supplier._id} onClick={this.toggleProductDetails}>
                                <FontAwesomeIcon icon='th-list' className="toggle-supplier-section"/>
                            </button>
                        </div>
                    </div>
                    {expand
                        ? this.supplierSection(supplier)
                        : null
                    }
                </div>
            )
        })
    }

    componentWillMount() {
        this.getProductCategories()
    }

    render() {
        const { isLoading } = this.props
        if (isLoading)
            return null
        return (
            <div className="container app-content product-catalog">
                {this.mapSupplierSections()}
            </div>
        )
    }
}

export default withTracker(() => {
    return {
        user: Meteor.user(),
        isLoading: !Meteor.user()
    }
})(ProductCatalog)
Wu Wei
  • 1,827
  • 1
  • 15
  • 27
  • Have you thought about making a service JS Class, putting reusable methods in said class and instantiating the class in components that use those methods? Not sure if that would work for your use case but, just a thought. – seanulus May 30 '19 at 23:24
  • 2
    sounds like a good scenario to use HOC – fangzhzh May 30 '19 at 23:32
  • @seanulus yes, but how can I bind them to the component's this, especially if it's an arrow function? – Wu Wei May 31 '19 at 04:51
  • @fagnzhzh Thanks. I learned that parent component !== HOC. Had a complete misconception of the term higher order component. – Wu Wei May 31 '19 at 05:19

1 Answers1

0

Since React 16.8 we have a new solution to this called hooks, custom hooks, to be precise. Custom hooks allow us to extract stateful component logic into a function and then reuse it in other components. However they are limited to functional components. You can't call a hook function within a class component, unfortunately.

(So this is not a solution for me, as I've written class components here, and I won't rewrite them for now. I'll have to stick with HOCs)

You can read more about hooks in the official docs. Sounds very promising and seems to be the future way of doing React.

Wu Wei
  • 1,827
  • 1
  • 15
  • 27