Passed
Push — task/explore-skills-ui ( d41a63...af1244 )
by Yonathan
06:51 queued 10s
created

singleResourceHook.ts ➔ useResource   F

Complexity

Conditions 15

Size

Total Lines 101
Code Lines 81

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 81
dl 0
loc 101
rs 2.7218
c 0
b 0
f 0
cc 15

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like singleResourceHook.ts ➔ useResource often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import { Reducer, useCallback, useEffect, useReducer, useRef } from "react";
2
import {
3
  FetchError,
4
  getRequest,
5
  processJsonResponse,
6
  putRequest,
7
} from "../../helpers/httpRequests";
8
import { decrement, identity } from "../../helpers/queries";
9
import { Json, ResourceStatus } from "./types";
10
11
export interface ResourceState<T> {
12
  value: T;
13
  status: ResourceStatus;
14
  pendingCount: number;
15
  error: Error | FetchError | undefined;
16
}
17
18
export enum ActionTypes {
19
  GetStart = "GET_START",
20
  GetFulfill = "GET_FULFILL",
21
  GetReject = "GET_REJECT",
22
23
  UpdateStart = "UPDATE_START",
24
  UpdateFulfill = "UPDATE_FULFILL",
25
  UpdateReject = "UPDATE_REJECT",
26
}
27
28
export type GetStartAction = { type: ActionTypes.GetStart };
29
export type GetFulfillAction<T> = {
30
  type: ActionTypes.GetFulfill;
31
  payload: T;
32
};
33
export type GetRejectAction = {
34
  type: ActionTypes.GetReject;
35
  payload: Error | FetchError;
36
};
37
38
export type UpdateStartAction<T> = {
39
  type: ActionTypes.UpdateStart;
40
  meta: { item: T };
41
};
42
export type UpdateFulfillAction<T> = {
43
  type: ActionTypes.UpdateFulfill;
44
  payload: T;
45
  meta: { item: T };
46
};
47
export type UpdateRejectAction<T> = {
48
  type: ActionTypes.UpdateReject;
49
  payload: Error | FetchError;
50
  meta: { item: T };
51
};
52
export type AsyncAction<T> =
53
  | GetStartAction
54
  | GetFulfillAction<T>
55
  | GetRejectAction
56
  | UpdateStartAction<T>
57
  | UpdateFulfillAction<T>
58
  | UpdateRejectAction<T>;
59
60
export function initialState<T>(initialValue: T): ResourceState<T> {
61
  return {
62
    value: initialValue,
63
    status: "initial",
64
    pendingCount: 0,
65
    error: undefined,
66
  };
67
}
68
69
export function reducer<T>(
70
  state: ResourceState<T>,
71
  action: AsyncAction<T>,
72
): ResourceState<T> {
73
  switch (action.type) {
74
    case ActionTypes.GetStart:
75
    case ActionTypes.UpdateStart: // TODO: For now GET and UPDATE actions can be treated the same. If we want to add optimistic updates, this can change.
76
      return {
77
        value: state.value,
78
        status: "pending",
79
        pendingCount: state.pendingCount + 1,
80
        error: undefined,
81
      };
82
    case ActionTypes.GetFulfill:
83
    case ActionTypes.UpdateFulfill:
84
      return {
85
        value: action.payload,
86
        status: state.pendingCount <= 1 ? "fulfilled" : "pending",
87
        pendingCount: decrement(state.pendingCount),
88
        error: undefined,
89
      };
90
    case ActionTypes.GetReject:
91
    case ActionTypes.UpdateReject:
92
      return {
93
        value: state.value,
94
        status: state.pendingCount <= 1 ? "rejected" : "pending",
95
        pendingCount: decrement(state.pendingCount),
96
        error: action.payload,
97
      };
98
    default:
99
      return state;
100
  }
101
}
102
103
function doNothing(): void {
104
  /* do nothing */
105
}
106
107
export function useResource<T>(
108
  endpoint: string,
109
  initialValue: T,
110
  overrides?: {
111
    parseResponse?: (response: Json) => T; // Defaults to the identity function.
112
    skipInitialFetch?: boolean; // Defaults to false. Override if you want to keep the initialValue until refresh is called manually.
113
    handleError?: (error: Error | FetchError) => void; // In addition to using the error returned by the hook, you may provide a callback called on every new error.
114
  },
115
): {
116
  value: T;
117
  status: ResourceStatus;
118
  error: undefined | Error | FetchError;
119
  update: (newValue: T) => Promise<T>;
120
  refresh: () => Promise<T>;
121
} {
122
  const parseResponse = overrides?.parseResponse ?? identity;
123
  const doInitialRefresh = overrides?.skipInitialFetch !== true;
124
  const handleError = overrides?.handleError ?? doNothing;
125
  const isSubscribed = useRef(true);
126
127
  const [state, dispatch] = useReducer<
128
    Reducer<ResourceState<T>, AsyncAction<T>>
129
  >(reducer, initialState(initialValue));
130
131
  const refresh = useCallback(async (): Promise<T> => {
132
    dispatch({ type: ActionTypes.GetStart });
133
    let json: Json;
134
    try {
135
      json = await getRequest(endpoint).then(processJsonResponse);
136
    } catch (error) {
137
      if (isSubscribed.current) {
138
        dispatch({
139
          type: ActionTypes.GetReject,
140
          payload: error,
141
        });
142
        handleError(error);
143
      }
144
      throw error;
145
    }
146
    const responseValue = parseResponse(json) as T;
147
    if (isSubscribed.current) {
148
      dispatch({
149
        type: ActionTypes.GetFulfill,
150
        payload: responseValue,
151
      });
152
    }
153
    return responseValue;
154
  }, [endpoint, parseResponse, handleError]);
155
156
  const update = useCallback(
157
    async (newValue): Promise<T> => {
158
      dispatch({ type: ActionTypes.UpdateStart, meta: { item: newValue } });
159
      let json: Json;
160
      try {
161
        json = await putRequest(endpoint, newValue).then(processJsonResponse);
162
      } catch (error) {
163
        if (isSubscribed.current) {
164
          dispatch({
165
            type: ActionTypes.UpdateReject,
166
            payload: error,
167
            meta: { item: newValue },
168
          });
169
          handleError(error);
170
        }
171
        throw error;
172
      }
173
      const responseValue = parseResponse(json) as T;
174
      if (isSubscribed.current) {
175
        dispatch({
176
          type: ActionTypes.UpdateFulfill,
177
          payload: responseValue,
178
          meta: { item: newValue },
179
        });
180
      }
181
      return responseValue;
182
    },
183
    [endpoint, parseResponse, handleError],
184
  );
185
186
  // Despite the usual guidlines, this should only be reconsidered if endpoint changes.
187
  // Changing doInitialRefresh after the first run (or the refresh function) should not cause this to rerun.
188
  useEffect(() => {
189
    if (doInitialRefresh) {
190
      refresh().catch(doNothing);
191
    }
192
193
    // Unsubscribe from promises when this hook is unmounted.
194
    return (): void => {
195
      isSubscribed.current = false;
196
    };
197
198
    // eslint-disable-next-line react-hooks/exhaustive-deps
199
  }, [endpoint]);
200
201
  return {
202
    value: state.value,
203
    status: state.status,
204
    error: state.error,
205
    update,
206
    refresh,
207
  };
208
}
209
210
export default useResource;
211